@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/CHANGELOG.md +90 -0
- package/README.md +36 -4
- package/dist/cjs/broadcast.d.ts +21 -2
- package/dist/cjs/broadcast.d.ts.map +1 -1
- package/dist/cjs/broadcast.js +59 -4
- package/dist/cjs/broadcast.js.map +1 -1
- package/dist/cjs/chat.d.ts +2 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +27 -6
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/index.d.ts +67 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +299 -67
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- package/dist/esm/broadcast.d.ts +21 -2
- package/dist/esm/broadcast.d.ts.map +1 -1
- package/dist/esm/broadcast.js +59 -4
- package/dist/esm/broadcast.js.map +1 -1
- package/dist/esm/chat.d.ts +2 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +27 -6
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/index.d.ts +67 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +299 -67
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- package/package.json +1 -1
- package/src/broadcast.ts +52 -4
- package/src/chat.ts +28 -6
- package/src/index.ts +295 -61
- package/src/recording.ts +59 -21
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|