@voltkit-rtc/client 0.7.0 → 0.7.2

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/src/chat.ts CHANGED
@@ -93,6 +93,8 @@ interface Pending {
93
93
  resolve: (v: unknown) => void;
94
94
  reject: (e: Error) => void;
95
95
  timer: ReturnType<typeof setTimeout>;
96
+ /** The socket it was sent on: only that socket can answer it. */
97
+ ws: WebSocket;
96
98
  }
97
99
 
98
100
  const RPC_TIMEOUT_MS = 15_000;
@@ -112,6 +114,11 @@ export class ChatClient {
112
114
  private token: string;
113
115
  private joinResult: ChatJoinResult | null = null;
114
116
  private closedByUs = false;
117
+ /**
118
+ * Bumped by close(). A connect() under way when it ran -- the app's, or a
119
+ * reconnect's -- sees the change after its await and stops.
120
+ */
121
+ private lifecycle = 0;
115
122
  private reconnecting = false;
116
123
  private reconnectAttempt = 0;
117
124
  /** Set by the first successful join. Only a session that has joined is reconnected. */
@@ -147,15 +154,23 @@ export class ChatClient {
147
154
  }
148
155
 
149
156
  async connect(): Promise<ChatJoinResult> {
157
+ const lifecycle = this.lifecycle;
150
158
  let result: ChatJoinResult;
159
+ // The socket this call opens. A later connect() replaces it, and this
160
+ // call's failure must then leave the newer socket alone.
161
+ let socket: WebSocket | null = null;
151
162
  try {
152
- await this.openSocket();
163
+ const opening = this.openSocket();
164
+ socket = this.ws;
165
+ await opening;
153
166
  result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
167
+ if (lifecycle !== this.lifecycle) throw new Error('the chat was closed while connecting');
154
168
  } catch (err) {
155
169
  // Never joined: the app has the error. Left open, the socket would be
156
170
  // closed by the server's join deadline, and that would start
157
171
  // reconnecting a session that never began.
158
- if (!this.joinedOnce) this.detachSocket();
172
+ const superseded = this.ws !== null && this.ws !== socket;
173
+ if (!this.joinedOnce && !superseded) this.detachSocket();
159
174
  throw err;
160
175
  }
161
176
  // Joined: the next drop gets every reconnect attempt again. Not when the
@@ -203,16 +218,30 @@ export class ChatClient {
203
218
  });
204
219
  }
205
220
 
206
- private detachSocket(): void {
221
+ private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
207
222
  const ws = this.ws;
208
223
  this.ws = null;
209
- if (ws && ws.readyState <= WebSocket.OPEN) {
224
+ if (!ws) return;
225
+ if (ws.readyState <= WebSocket.OPEN) {
210
226
  try {
211
- ws.close(1000, 'superseded');
227
+ ws.close(1000, reason);
212
228
  } catch {
213
229
  /* already closing */
214
230
  }
215
231
  }
232
+ // Its replies are no longer read: what waits on it fails now instead of
233
+ // waiting out its timeout. Never replayed.
234
+ this.failPending(ws, new Error(why));
235
+ }
236
+
237
+ /**
238
+ * A reconnect that ends the session lets go of its socket: a refused join
239
+ * leaves it open, and it would otherwise stay attached, and sending, after
240
+ * closed.
241
+ */
242
+ private releaseSocket(): void {
243
+ this.detachSocket('gave up', 'the chat has ended');
244
+ this.failAllPending(new Error('the chat has ended'));
216
245
  }
217
246
 
218
247
  /** Single-flight, with backoff: the same shape as the room client's. */
@@ -224,6 +253,7 @@ export class ChatClient {
224
253
  this.reconnectAttempt += 1;
225
254
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
226
255
  this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
256
+ this.releaseSocket();
227
257
  this.closedByUs = true;
228
258
  this.emit('closed', 'gave-up');
229
259
  return;
@@ -235,11 +265,15 @@ export class ChatClient {
235
265
  await this.connect();
236
266
  return;
237
267
  } catch (err) {
268
+ // Closed while this attempt was under way, and that has been
269
+ // reported: nothing follows closed.
270
+ if (this.closedByUs) return;
238
271
  this.emit('error', err as Error);
239
272
  // A token that is wrong will be wrong next time too, and an account
240
273
  // that ran out stays out until topped up: the registry's terminal
241
274
  // codes, shared with StreamClient. Retrying only adds noise.
242
275
  if (err instanceof ChatRpcError && isTerminalError(err.code)) {
276
+ this.releaseSocket();
243
277
  this.closedByUs = true;
244
278
  this.emit('closed', 'gave-up');
245
279
  return;
@@ -252,12 +286,20 @@ export class ChatClient {
252
286
  }
253
287
 
254
288
  close(): void {
255
- if (this.closedByUs) return;
289
+ // Whatever socket is held is released even when the session has already
290
+ // ended: one opened by a connect() after an earlier close() would
291
+ // otherwise be out of reach. `closed` is still reported once.
292
+ const wasOpen = !this.closedByUs;
256
293
  this.closedByUs = true;
294
+ this.lifecycle += 1;
257
295
  const ws = this.ws;
258
296
  this.ws = null;
259
297
  ws?.close(1000, 'client closed');
260
- this.emit('closed', 'left');
298
+ // Detached first, so the socket's own close handler no longer fails what
299
+ // was waiting on it: done here, or a join or a send in flight would wait
300
+ // out its 15 s timeout.
301
+ this.failAllPending(new Error('the chat was closed'));
302
+ if (wasOpen) this.emit('closed', 'left');
261
303
  }
262
304
 
263
305
  private rpc(method: string, params: unknown = {}): Promise<unknown> {
@@ -271,11 +313,21 @@ export class ChatClient {
271
313
  this.pending.delete(id);
272
314
  reject(new Error(`${method} timed out`));
273
315
  }, RPC_TIMEOUT_MS);
274
- this.pending.set(id, { resolve, reject, timer });
316
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
275
317
  this.ws.send(JSON.stringify({ id, method, params }));
276
318
  });
277
319
  }
278
320
 
321
+ /** Fail what was sent on `ws` and is still waiting. */
322
+ private failPending(ws: WebSocket, err: Error): void {
323
+ for (const [id, p] of this.pending) {
324
+ if (p.ws !== ws) continue;
325
+ clearTimeout(p.timer);
326
+ this.pending.delete(id);
327
+ p.reject(err);
328
+ }
329
+ }
330
+
279
331
  private failAllPending(err: Error): void {
280
332
  for (const p of this.pending.values()) {
281
333
  clearTimeout(p.timer);
@@ -291,6 +343,9 @@ export class ChatClient {
291
343
  } catch {
292
344
  return;
293
345
  }
346
+ // Valid JSON that is not a message object ("null" would throw below) is
347
+ // ignored like any other frame that is not a message.
348
+ if (typeof msg !== 'object' || msg === null) return;
294
349
  if (typeof msg.id === 'number') {
295
350
  const p = this.pending.get(msg.id);
296
351
  if (!p) return;