@voltkit-rtc/client 0.7.1 → 0.7.3

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/broadcast.ts CHANGED
@@ -66,6 +66,9 @@ const ICE_GATHER_TIMEOUT_MS = 3_000;
66
66
  /** Helper formats that ride along with a codec. Not codecs in their own right. */
67
67
  const HELPER_FORMATS = new Set(['video/rtx', 'video/red', 'video/ulpfec', 'video/flexfec-03']);
68
68
 
69
+ /** Why a start() gave up: stop() ran while it was under way, and stop wins. */
70
+ const stoppedWhileStarting = () => new DOMException('the broadcast was stopped while it was starting', 'AbortError');
71
+
69
72
  /**
70
73
  * Send an SDP offer to a WHIP endpoint and return the answer, plus the session
71
74
  * resource the server names for ending it.
@@ -149,6 +152,16 @@ export class BroadcastPublisher {
149
152
  private micOn = true;
150
153
  private current: BroadcastState = 'idle';
151
154
  private readonly listeners = new Map<string, Set<Handler>>();
155
+ /**
156
+ * Bumped by every start() and stop(). A start compares it after each wait:
157
+ * once stop() has run, that start is over, and what it was waiting for
158
+ * (a camera, the media server's answer) arrives to be released, never
159
+ * adopted -- otherwise a stopped broadcast went on air anyway, or tore
160
+ * down the one started after it.
161
+ */
162
+ private operation = 0;
163
+ /** The start() under way, by its operation; a second one is refused meanwhile. */
164
+ private starting: number | null = null;
152
165
 
153
166
  constructor(private readonly whipUrl: string) {}
154
167
 
@@ -187,15 +200,27 @@ export class BroadcastPublisher {
187
200
  this.emit('state', state);
188
201
  }
189
202
 
190
- /** Go on air. Resolves once the media server has accepted the stream. */
203
+ /**
204
+ * Go on air. Resolves once the media server has accepted the stream.
205
+ * Rejects with an `AbortError` when `stop()` runs first: a camera captured
206
+ * for it afterwards is released, and a session the server opened for it is
207
+ * ended.
208
+ */
191
209
  async start(options: BroadcastStartOptions = {}): Promise<MediaStream> {
192
- if (this.pc) throw new Error('already publishing; call stop() first');
210
+ if (this.pc || this.starting !== null) throw new Error('already publishing; call stop() first');
211
+ const operation = ++this.operation;
212
+ this.starting = operation;
213
+ const stopped = () => operation !== this.operation;
214
+ const owns = !options.stream;
215
+ let captured: MediaStream | null = null;
193
216
  this.set('connecting');
194
217
  try {
195
- this.ownsMedia = !options.stream;
196
218
  const media = options.stream ?? await navigator.mediaDevices.getUserMedia(
197
219
  options.constraints ?? { audio: true, video: { width: 1280, height: 720 } },
198
220
  );
221
+ captured = media;
222
+ if (stopped()) throw stoppedWhileStarting();
223
+ this.ownsMedia = owns;
199
224
  this.media = media;
200
225
 
201
226
  const pc = new RTCPeerConnection();
@@ -225,17 +250,34 @@ export class BroadcastPublisher {
225
250
 
226
251
  await pc.setLocalDescription(await pc.createOffer());
227
252
  await iceGathered(pc);
253
+ if (stopped()) throw stoppedWhileStarting();
228
254
  const offerSdp = pc.localDescription?.sdp;
229
255
  if (!offerSdp) throw new WhipError(0, 'could not create an offer in this browser');
230
256
 
231
257
  const answer = await whipOffer(this.whipUrl, offerSdp);
258
+ if (stopped()) {
259
+ // The server opened a session for a start that is over: end it, as
260
+ // stop() would have, had it known of it.
261
+ if (answer.resource) void whipEnd(answer.resource);
262
+ throw stoppedWhileStarting();
263
+ }
232
264
  this.resource = answer.resource;
233
265
  await pc.setRemoteDescription({ type: 'answer', sdp: answer.answerSdp });
266
+ if (stopped()) throw stoppedWhileStarting();
234
267
  return media;
235
268
  } catch (err) {
269
+ if (stopped()) {
270
+ // stop() released what this start had handed over, and a newer
271
+ // start() may be on air now: only what arrived after stop() is ours
272
+ // to release here.
273
+ if (owns && captured) for (const t of captured.getTracks()) t.stop();
274
+ throw stoppedWhileStarting();
275
+ }
236
276
  this.teardown();
237
277
  this.set('failed');
238
278
  throw err;
279
+ } finally {
280
+ if (this.starting === operation) this.starting = null;
239
281
  }
240
282
  }
241
283
 
@@ -317,8 +359,14 @@ export class BroadcastPublisher {
317
359
  for (const t of this.media?.getVideoTracks() ?? []) t.enabled = enabled;
318
360
  }
319
361
 
320
- /** Go off air and release the camera and microphone. Idempotent. */
362
+ /**
363
+ * Go off air and release the camera and microphone. Idempotent. A start()
364
+ * still under way is over: it rejects with an `AbortError`, and you can
365
+ * start again at once.
366
+ */
321
367
  async stop(): Promise<void> {
368
+ this.operation += 1;
369
+ this.starting = null;
322
370
  const resource = this.resource;
323
371
  this.teardown();
324
372
  if (resource) await whipEnd(resource);
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;
@@ -154,15 +156,21 @@ export class ChatClient {
154
156
  async connect(): Promise<ChatJoinResult> {
155
157
  const lifecycle = this.lifecycle;
156
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;
157
162
  try {
158
- await this.openSocket();
163
+ const opening = this.openSocket();
164
+ socket = this.ws;
165
+ await opening;
159
166
  result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
160
167
  if (lifecycle !== this.lifecycle) throw new Error('the chat was closed while connecting');
161
168
  } catch (err) {
162
169
  // Never joined: the app has the error. Left open, the socket would be
163
170
  // closed by the server's join deadline, and that would start
164
171
  // reconnecting a session that never began.
165
- if (!this.joinedOnce) this.detachSocket();
172
+ const superseded = this.ws !== null && this.ws !== socket;
173
+ if (!this.joinedOnce && !superseded) this.detachSocket();
166
174
  throw err;
167
175
  }
168
176
  // Joined: the next drop gets every reconnect attempt again. Not when the
@@ -210,16 +218,20 @@ export class ChatClient {
210
218
  });
211
219
  }
212
220
 
213
- private detachSocket(reason = 'superseded'): void {
221
+ private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
214
222
  const ws = this.ws;
215
223
  this.ws = null;
216
- if (ws && ws.readyState <= WebSocket.OPEN) {
224
+ if (!ws) return;
225
+ if (ws.readyState <= WebSocket.OPEN) {
217
226
  try {
218
227
  ws.close(1000, reason);
219
228
  } catch {
220
229
  /* already closing */
221
230
  }
222
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));
223
235
  }
224
236
 
225
237
  /**
@@ -228,7 +240,7 @@ export class ChatClient {
228
240
  * closed.
229
241
  */
230
242
  private releaseSocket(): void {
231
- this.detachSocket('gave up');
243
+ this.detachSocket('gave up', 'the chat has ended');
232
244
  this.failAllPending(new Error('the chat has ended'));
233
245
  }
234
246
 
@@ -301,11 +313,21 @@ export class ChatClient {
301
313
  this.pending.delete(id);
302
314
  reject(new Error(`${method} timed out`));
303
315
  }, RPC_TIMEOUT_MS);
304
- this.pending.set(id, { resolve, reject, timer });
316
+ this.pending.set(id, { resolve, reject, timer, ws: this.ws });
305
317
  this.ws.send(JSON.stringify({ id, method, params }));
306
318
  });
307
319
  }
308
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
+
309
331
  private failAllPending(err: Error): void {
310
332
  for (const p of this.pending.values()) {
311
333
  clearTimeout(p.timer);