@voltkit-rtc/client 0.6.1 → 0.7.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +109 -2
  2. package/README.md +236 -11
  3. package/dist/cjs/broadcast.d.ts.map +1 -1
  4. package/dist/cjs/broadcast.js +16 -7
  5. package/dist/cjs/broadcast.js.map +1 -1
  6. package/dist/cjs/chat.d.ts +11 -9
  7. package/dist/cjs/chat.d.ts.map +1 -1
  8. package/dist/cjs/chat.js +56 -19
  9. package/dist/cjs/chat.js.map +1 -1
  10. package/dist/cjs/errors.d.ts +571 -0
  11. package/dist/cjs/errors.d.ts.map +1 -0
  12. package/dist/cjs/errors.js +155 -0
  13. package/dist/cjs/errors.js.map +1 -0
  14. package/dist/cjs/index.d.ts +320 -16
  15. package/dist/cjs/index.d.ts.map +1 -1
  16. package/dist/cjs/index.js +728 -123
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/cjs/media-permissions.d.ts +101 -0
  19. package/dist/cjs/media-permissions.d.ts.map +1 -0
  20. package/dist/cjs/media-permissions.js +166 -0
  21. package/dist/cjs/media-permissions.js.map +1 -0
  22. package/dist/cjs/network.d.ts +80 -0
  23. package/dist/cjs/network.d.ts.map +1 -0
  24. package/dist/cjs/network.js +190 -0
  25. package/dist/cjs/network.js.map +1 -0
  26. package/dist/cjs/token.d.ts +17 -0
  27. package/dist/cjs/token.d.ts.map +1 -0
  28. package/dist/cjs/token.js +36 -0
  29. package/dist/cjs/token.js.map +1 -0
  30. package/dist/esm/broadcast.d.ts.map +1 -1
  31. package/dist/esm/broadcast.js +16 -7
  32. package/dist/esm/broadcast.js.map +1 -1
  33. package/dist/esm/chat.d.ts +11 -9
  34. package/dist/esm/chat.d.ts.map +1 -1
  35. package/dist/esm/chat.js +56 -19
  36. package/dist/esm/chat.js.map +1 -1
  37. package/dist/esm/errors.d.ts +571 -0
  38. package/dist/esm/errors.d.ts.map +1 -0
  39. package/dist/esm/errors.js +149 -0
  40. package/dist/esm/errors.js.map +1 -0
  41. package/dist/esm/index.d.ts +320 -16
  42. package/dist/esm/index.d.ts.map +1 -1
  43. package/dist/esm/index.js +707 -114
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/media-permissions.d.ts +101 -0
  46. package/dist/esm/media-permissions.d.ts.map +1 -0
  47. package/dist/esm/media-permissions.js +160 -0
  48. package/dist/esm/media-permissions.js.map +1 -0
  49. package/dist/esm/network.d.ts +80 -0
  50. package/dist/esm/network.d.ts.map +1 -0
  51. package/dist/esm/network.js +183 -0
  52. package/dist/esm/network.js.map +1 -0
  53. package/dist/esm/token.d.ts +17 -0
  54. package/dist/esm/token.d.ts.map +1 -0
  55. package/dist/esm/token.js +31 -0
  56. package/dist/esm/token.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/broadcast.ts +16 -7
  59. package/src/chat.ts +44 -18
  60. package/src/errors.ts +162 -0
  61. package/src/index.ts +842 -114
  62. package/src/media-permissions.ts +220 -0
  63. package/src/network.ts +212 -0
  64. package/src/token.ts +31 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Network quality, from the media transports' WebRTC statistics: round-trip
3
+ * time, packet loss in the worse direction, and the bandwidth the browser
4
+ * estimates it can use. A level sums them up for a UI, with documented
5
+ * thresholds (docs/SDK-CONTRACT.md, "Network quality").
6
+ *
7
+ * The parsers are pure, like stats.ts: they take the stats objects of a
8
+ * getStats() report and the previous sample's counters.
9
+ */
10
+ import type { StatsEntry } from './stats.js';
11
+ export type NetworkQualityLevel = 'excellent' | 'good' | 'poor' | 'bad';
12
+ export interface NetworkQuality {
13
+ level: NetworkQualityLevel;
14
+ /** Round trip to the media server, in ms; null before the browser has measured one. */
15
+ rttMs: number | null;
16
+ /** Packets lost over the last interval, in percent, the worse of sending and receiving; null when nothing moved. */
17
+ lossPct: number | null;
18
+ /** The bandwidth the browser estimates it can use, in kbps: upload while publishing, else download when reported. */
19
+ availableKbps: number | null;
20
+ }
21
+ /** How often the transports are sampled while someone listens for `networkQuality`. */
22
+ export declare const NETWORK_QUALITY_INTERVAL_MS = 3000;
23
+ /**
24
+ * The thresholds: the first level whose round trip and loss are both below
25
+ * its limits, else `bad`. An unknown value does not lower the level.
26
+ */
27
+ export declare const NETWORK_QUALITY_LEVELS: readonly {
28
+ level: Exclude<NetworkQualityLevel, 'bad'>;
29
+ rttMsBelow: number;
30
+ lossPctBelow: number;
31
+ }[];
32
+ export declare function networkLevel(rttMs: number | null, lossPct: number | null): NetworkQualityLevel;
33
+ /** One transport's counters at one moment; the next sample's deltas are taken against it. */
34
+ export interface TransportSample {
35
+ /** The selected candidate pair's current round trip, in ms. */
36
+ rttMs: number | null;
37
+ /** The round trip RTCP reports from the server, in ms (a fallback). */
38
+ remoteRttMs: number | null;
39
+ /** RTP packets sent, and how many of them the server reported lost. */
40
+ packetsSent: number;
41
+ remotePacketsLost: number;
42
+ /** RTP packets received, and how many were lost on the way. */
43
+ packetsReceived: number;
44
+ packetsLost: number;
45
+ /** Bits per second the browser estimates it can send / receive. */
46
+ availableOutgoing: number | null;
47
+ availableIncoming: number | null;
48
+ }
49
+ /** The counters of one transport's report. */
50
+ export declare function readTransportStats(report: Iterable<StatsEntry>): TransportSample;
51
+ /**
52
+ * The quality now, from each transport's sample and the one before it (by
53
+ * key; a transport that is new, or whose counters went backwards, gives no
54
+ * loss for this interval). `publishing` picks the upload estimate.
55
+ */
56
+ export declare function networkQualityOf(current: ReadonlyMap<string, TransportSample>, previous: ReadonlyMap<string, TransportSample>, publishing: boolean): NetworkQuality;
57
+ /**
58
+ * Samples the transports on a timer while wanted and reports each interval.
59
+ * The first report comes as soon as a round trip is known; loss needs the
60
+ * previous interval's counters, so it is null until the second.
61
+ */
62
+ export declare class NetworkQualityWatch {
63
+ /** Each live transport's report, by a key that changes when the transport does. */
64
+ private readonly sample;
65
+ private readonly publishing;
66
+ private readonly report;
67
+ private readonly intervalMs;
68
+ private timer;
69
+ private previous;
70
+ private sampling;
71
+ private generation;
72
+ constructor(
73
+ /** Each live transport's report, by a key that changes when the transport does. */
74
+ sample: () => Promise<Map<string, Iterable<StatsEntry>>>, publishing: () => boolean, report: (quality: NetworkQuality) => void, intervalMs?: number);
75
+ get running(): boolean;
76
+ /** Start or stop sampling. Safe to call as often as anything changes. */
77
+ set(wanted: boolean): void;
78
+ private tick;
79
+ }
80
+ //# sourceMappingURL=network.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/network.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAC;AAExE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,mBAAmB,CAAC;IAC3B,uFAAuF;IACvF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,oHAAoH;IACpH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,qHAAqH;IACrH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,uFAAuF;AACvF,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS;IAAE,KAAK,EAAE,OAAO,CAAC,mBAAmB,EAAE,KAAK,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,EAIrI,CAAC;AAEF,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,mBAAmB,CAK9F;AAED,6FAA6F;AAC7F,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,uEAAuE;IACvE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,uEAAuE;IACvE,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAKD,8CAA8C;AAC9C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,eAAe,CA8ChF;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,EAC7C,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,eAAe,CAAC,EAC9C,UAAU,EAAE,OAAO,GAClB,cAAc,CA4BhB;AAED;;;;GAIG;AACH,qBAAa,mBAAmB;IAO5B,mFAAmF;IACnF,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAV7B,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAAK;;IAGrB,mFAAmF;IAClE,MAAM,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,EACxD,UAAU,EAAE,MAAM,OAAO,EACzB,MAAM,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,IAAI,EACzC,UAAU,SAA8B;IAG3D,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,yEAAyE;IACzE,GAAG,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;YAYZ,IAAI;CAsBnB"}
@@ -0,0 +1,183 @@
1
+ /** How often the transports are sampled while someone listens for `networkQuality`. */
2
+ export const NETWORK_QUALITY_INTERVAL_MS = 3000;
3
+ /**
4
+ * The thresholds: the first level whose round trip and loss are both below
5
+ * its limits, else `bad`. An unknown value does not lower the level.
6
+ */
7
+ export const NETWORK_QUALITY_LEVELS = [
8
+ { level: 'excellent', rttMsBelow: 150, lossPctBelow: 1 },
9
+ { level: 'good', rttMsBelow: 300, lossPctBelow: 3 },
10
+ { level: 'poor', rttMsBelow: 500, lossPctBelow: 8 },
11
+ ];
12
+ export function networkLevel(rttMs, lossPct) {
13
+ for (const l of NETWORK_QUALITY_LEVELS) {
14
+ if ((rttMs === null || rttMs < l.rttMsBelow) && (lossPct === null || lossPct < l.lossPctBelow))
15
+ return l.level;
16
+ }
17
+ return 'bad';
18
+ }
19
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
20
+ const optNum = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
21
+ /** The counters of one transport's report. */
22
+ export function readTransportStats(report) {
23
+ const entries = [...report].filter((e) => typeof e === 'object' && e !== null);
24
+ const byId = new Map();
25
+ for (const entry of entries)
26
+ if (typeof entry.id === 'string')
27
+ byId.set(entry.id, entry);
28
+ // The candidate pair in use: the transport's selected one, else a nominated
29
+ // succeeded pair (Firefox reports no transport entry).
30
+ let pair;
31
+ for (const transport of entries.filter((e) => e.type === 'transport')) {
32
+ const selected = typeof transport.selectedCandidatePairId === 'string' ? byId.get(transport.selectedCandidatePairId) : undefined;
33
+ if (selected) {
34
+ pair = selected;
35
+ break;
36
+ }
37
+ }
38
+ if (!pair) {
39
+ const pairs = entries.filter((e) => e.type === 'candidate-pair');
40
+ pair = pairs.find((p) => p.nominated === true && p.state === 'succeeded') ?? pairs.find((p) => p.selected === true);
41
+ }
42
+ const rtt = optNum(pair?.currentRoundTripTime);
43
+ let remoteRtt;
44
+ let packetsSent = 0;
45
+ let remotePacketsLost = 0;
46
+ let packetsReceived = 0;
47
+ let packetsLost = 0;
48
+ for (const e of entries) {
49
+ if (e.type === 'outbound-rtp')
50
+ packetsSent += num(e.packetsSent);
51
+ else if (e.type === 'remote-inbound-rtp') {
52
+ remotePacketsLost += Math.max(0, num(e.packetsLost));
53
+ const r = optNum(e.roundTripTime);
54
+ if (r !== undefined)
55
+ remoteRtt = Math.max(remoteRtt ?? 0, r);
56
+ }
57
+ else if (e.type === 'inbound-rtp') {
58
+ packetsReceived += num(e.packetsReceived);
59
+ packetsLost += Math.max(0, num(e.packetsLost));
60
+ }
61
+ }
62
+ const out = optNum(pair?.availableOutgoingBitrate);
63
+ const inc = optNum(pair?.availableIncomingBitrate);
64
+ return {
65
+ rttMs: rtt === undefined ? null : Math.round(rtt * 1000),
66
+ remoteRttMs: remoteRtt === undefined ? null : Math.round(remoteRtt * 1000),
67
+ packetsSent,
68
+ remotePacketsLost,
69
+ packetsReceived,
70
+ packetsLost,
71
+ availableOutgoing: out ?? null,
72
+ availableIncoming: inc ?? null,
73
+ };
74
+ }
75
+ const pct = (lost, total) => (total > 0 ? Math.round((Math.min(lost, total) / total) * 1000) / 10 : null);
76
+ /**
77
+ * The quality now, from each transport's sample and the one before it (by
78
+ * key; a transport that is new, or whose counters went backwards, gives no
79
+ * loss for this interval). `publishing` picks the upload estimate.
80
+ */
81
+ export function networkQualityOf(current, previous, publishing) {
82
+ let rttMs = null;
83
+ let lossPct = null;
84
+ let upload = null;
85
+ let download = null;
86
+ for (const [key, now] of current) {
87
+ const rtt = now.rttMs ?? now.remoteRttMs;
88
+ if (rtt !== null)
89
+ rttMs = Math.max(rttMs ?? 0, rtt);
90
+ if (now.availableOutgoing !== null)
91
+ upload = Math.max(upload ?? 0, now.availableOutgoing);
92
+ if (now.availableIncoming !== null)
93
+ download = Math.max(download ?? 0, now.availableIncoming);
94
+ const before = previous.get(key);
95
+ if (!before)
96
+ continue;
97
+ const sent = now.packetsSent - before.packetsSent;
98
+ const remoteLost = now.remotePacketsLost - before.remotePacketsLost;
99
+ const received = now.packetsReceived - before.packetsReceived;
100
+ const lost = now.packetsLost - before.packetsLost;
101
+ // Counters only go forward on one transport; backwards is a new one.
102
+ const up = sent >= 0 && remoteLost >= 0 ? pct(remoteLost, sent) : null;
103
+ const down = received >= 0 && lost >= 0 ? pct(lost, received + lost) : null;
104
+ for (const value of [up, down])
105
+ if (value !== null)
106
+ lossPct = Math.max(lossPct ?? 0, value);
107
+ }
108
+ const bandwidth = publishing ? upload ?? download : download ?? upload;
109
+ return {
110
+ level: networkLevel(rttMs, lossPct),
111
+ rttMs,
112
+ lossPct,
113
+ availableKbps: bandwidth === null ? null : Math.round(bandwidth / 1000),
114
+ };
115
+ }
116
+ /**
117
+ * Samples the transports on a timer while wanted and reports each interval.
118
+ * The first report comes as soon as a round trip is known; loss needs the
119
+ * previous interval's counters, so it is null until the second.
120
+ */
121
+ export class NetworkQualityWatch {
122
+ sample;
123
+ publishing;
124
+ report;
125
+ intervalMs;
126
+ timer = null;
127
+ previous = new Map();
128
+ sampling = false;
129
+ generation = 0;
130
+ constructor(
131
+ /** Each live transport's report, by a key that changes when the transport does. */
132
+ sample, publishing, report, intervalMs = NETWORK_QUALITY_INTERVAL_MS) {
133
+ this.sample = sample;
134
+ this.publishing = publishing;
135
+ this.report = report;
136
+ this.intervalMs = intervalMs;
137
+ }
138
+ get running() {
139
+ return this.timer !== null;
140
+ }
141
+ /** Start or stop sampling. Safe to call as often as anything changes. */
142
+ set(wanted) {
143
+ if (wanted && !this.timer) {
144
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
145
+ void this.tick();
146
+ }
147
+ else if (!wanted && this.timer) {
148
+ clearInterval(this.timer);
149
+ this.timer = null;
150
+ this.generation += 1;
151
+ this.previous = new Map();
152
+ }
153
+ }
154
+ async tick() {
155
+ if (this.sampling)
156
+ return;
157
+ const generation = this.generation;
158
+ this.sampling = true;
159
+ try {
160
+ const reports = await this.sample();
161
+ if (generation !== this.generation || !this.timer)
162
+ return;
163
+ const current = new Map();
164
+ for (const [key, report] of reports)
165
+ current.set(key, readTransportStats(report));
166
+ const first = this.previous.size === 0;
167
+ const quality = networkQualityOf(current, this.previous, this.publishing());
168
+ this.previous = current;
169
+ // Nothing to report without a transport, or before a second sample
170
+ // when not even the round trip is known yet.
171
+ if (current.size === 0 || (first && quality.rttMs === null))
172
+ return;
173
+ this.report(quality);
174
+ }
175
+ catch {
176
+ // Statistics are best effort; the next interval tries again.
177
+ }
178
+ finally {
179
+ this.sampling = false;
180
+ }
181
+ }
182
+ }
183
+ //# sourceMappingURL=network.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network.js","sourceRoot":"","sources":["../../src/network.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * When a room token expires, read from the token itself. Not verified -- the
3
+ * server does that -- and only used to warn before the token the SDK would
4
+ * reconnect with lapses (`tokenExpiring`).
5
+ */
6
+ /** How long before the token's expiry `tokenExpiring` fires. */
7
+ export declare const TOKEN_EXPIRING_LEAD_MS = 60000;
8
+ /**
9
+ * The socket URL with the token as its `token` query parameter. Joined by
10
+ * hand rather than with `URL.searchParams`: React Native's `URL` is a partial
11
+ * polyfill (older releases had no `searchParams.set`), and a WebSocket URL has
12
+ * no fragment, so the string form is the same as the URL object's.
13
+ */
14
+ export declare function socketUrl(wsUrl: string, token: string): string;
15
+ /** The `exp` claim of a JWT, in unix milliseconds; null for a token that is not a readable JWT. */
16
+ export declare function tokenExpiresAt(token: string): number | null;
17
+ //# sourceMappingURL=token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../../src/token.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,gEAAgE;AAChE,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,mGAAmG;AACnG,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAU3D"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * When a room token expires, read from the token itself. Not verified -- the
3
+ * server does that -- and only used to warn before the token the SDK would
4
+ * reconnect with lapses (`tokenExpiring`).
5
+ */
6
+ /** How long before the token's expiry `tokenExpiring` fires. */
7
+ export const TOKEN_EXPIRING_LEAD_MS = 60_000;
8
+ /**
9
+ * The socket URL with the token as its `token` query parameter. Joined by
10
+ * hand rather than with `URL.searchParams`: React Native's `URL` is a partial
11
+ * polyfill (older releases had no `searchParams.set`), and a WebSocket URL has
12
+ * no fragment, so the string form is the same as the URL object's.
13
+ */
14
+ export function socketUrl(wsUrl, token) {
15
+ return `${wsUrl}${wsUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
16
+ }
17
+ /** The `exp` claim of a JWT, in unix milliseconds; null for a token that is not a readable JWT. */
18
+ export function tokenExpiresAt(token) {
19
+ const payload = typeof token === 'string' ? token.split('.')[1] : undefined;
20
+ if (!payload || typeof globalThis.atob !== 'function')
21
+ return null;
22
+ try {
23
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
24
+ const json = JSON.parse(globalThis.atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4)));
25
+ return typeof json.exp === 'number' && Number.isFinite(json.exp) && json.exp > 0 ? json.exp * 1000 : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ //# sourceMappingURL=token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token.js","sourceRoot":"","sources":["../../src/token.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,gEAAgE;AAChE,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC;AAE7C;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,KAAa;IACpD,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,SAAS,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC;AACxF,CAAC;AAED,mGAAmG;AACnG,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,MAAM,OAAO,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5E,IAAI,CAAC,OAAO,IAAI,OAAO,UAAU,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IACnE,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAsB,CAAC;QAClH,OAAO,OAAO,IAAI,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5G,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltkit-rtc/client",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.",
5
5
  "keywords": [
6
6
  "voltkit",
package/src/broadcast.ts CHANGED
@@ -75,22 +75,31 @@ export async function whipOffer(
75
75
  offerSdp: string,
76
76
  timeoutMs = WHIP_TIMEOUT_MS,
77
77
  ): Promise<{ answerSdp: string; resource: string | null }> {
78
+ // One deadline for the request and the whole answer: the signal stays
79
+ // armed while the body is read.
80
+ const signal = AbortSignal.timeout(timeoutMs);
81
+ // Never echo the URL or the error: the URL carries the publish token.
82
+ const failed = (err: unknown, otherwise: string) => new WhipError(0,
83
+ signal.aborted || (err as Error)?.name === 'TimeoutError' ? 'the media server did not answer in time' : otherwise);
78
84
  let res: Response;
79
85
  try {
80
86
  res = await fetch(whipUrl, {
81
87
  method: 'POST',
82
88
  headers: { 'Content-Type': 'application/sdp', Accept: 'application/sdp' },
83
89
  body: offerSdp,
84
- signal: AbortSignal.timeout(timeoutMs),
90
+ signal,
85
91
  });
86
92
  } catch (err) {
87
- // Never echo the URL: it carries the publish token.
88
- const timedOut = (err as Error)?.name === 'TimeoutError';
89
- throw new WhipError(0, timedOut
90
- ? 'the media server did not answer in time'
91
- : 'could not reach the media server');
93
+ throw failed(err, 'could not reach the media server');
94
+ }
95
+ let body: string;
96
+ try {
97
+ // A deadline or a connection lost after the headers is the same failure
98
+ // as one before them, and is reported the same way.
99
+ body = await res.text();
100
+ } catch (err) {
101
+ throw failed(err, 'the connection to the media server was lost');
92
102
  }
93
- const body = await res.text();
94
103
  if (res.status !== 201 && res.status !== 200) {
95
104
  const line = body.split('\n')[0]?.trim().slice(0, 200);
96
105
  throw new WhipError(res.status, line || 'the media server refused the stream');
package/src/chat.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * nothing else -- for an audience that is watching a broadcast over a CDN and
8
8
  * has no reason to be on an SFU.
9
9
  */
10
+ import { isTerminalError } from './errors.js';
10
11
  import type { Role } from './index.js';
12
+ import { socketUrl } from './token.js';
11
13
 
12
14
  export interface ChatMessage {
13
15
  /** Monotonic within a channel, so the oldest id held is a paging cursor. */
@@ -95,16 +97,6 @@ interface Pending {
95
97
 
96
98
  const RPC_TIMEOUT_MS = 15_000;
97
99
  const MAX_RECONNECT_ATTEMPTS = 8;
98
- /** A token that is wrong will be wrong next time too; retrying only adds noise. */
99
- const TERMINAL_JOIN_ERRORS = new Set([
100
- 'bad-token',
101
- 'no-token',
102
- // The account cannot use the service until it is topped up or restored.
103
- 'quota-exhausted',
104
- 'daily-limit-reached',
105
- 'monthly-limit-reached',
106
- 'tenant-suspended',
107
- ]);
108
100
 
109
101
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
110
102
 
@@ -120,6 +112,11 @@ export class ChatClient {
120
112
  private token: string;
121
113
  private joinResult: ChatJoinResult | null = null;
122
114
  private closedByUs = false;
115
+ /**
116
+ * Bumped by close(). A connect() under way when it ran -- the app's, or a
117
+ * reconnect's -- sees the change after its await and stops.
118
+ */
119
+ private lifecycle = 0;
123
120
  private reconnecting = false;
124
121
  private reconnectAttempt = 0;
125
122
  /** Set by the first successful join. Only a session that has joined is reconnected. */
@@ -155,10 +152,12 @@ export class ChatClient {
155
152
  }
156
153
 
157
154
  async connect(): Promise<ChatJoinResult> {
155
+ const lifecycle = this.lifecycle;
158
156
  let result: ChatJoinResult;
159
157
  try {
160
158
  await this.openSocket();
161
159
  result = (await this.rpc('join', { token: this.token })) as ChatJoinResult;
160
+ if (lifecycle !== this.lifecycle) throw new Error('the chat was closed while connecting');
162
161
  } catch (err) {
163
162
  // Never joined: the app has the error. Left open, the socket would be
164
163
  // closed by the server's join deadline, and that would start
@@ -179,9 +178,7 @@ export class ChatClient {
179
178
  private openSocket(): Promise<void> {
180
179
  this.detachSocket();
181
180
  return new Promise((resolve, reject) => {
182
- const url = new URL(this.wsUrl);
183
- url.searchParams.set('token', this.token);
184
- const ws = new WebSocket(url.toString());
181
+ const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
185
182
  this.ws = ws;
186
183
 
187
184
  const onError = () => reject(new Error('websocket failed to open'));
@@ -213,18 +210,28 @@ export class ChatClient {
213
210
  });
214
211
  }
215
212
 
216
- private detachSocket(): void {
213
+ private detachSocket(reason = 'superseded'): void {
217
214
  const ws = this.ws;
218
215
  this.ws = null;
219
216
  if (ws && ws.readyState <= WebSocket.OPEN) {
220
217
  try {
221
- ws.close(1000, 'superseded');
218
+ ws.close(1000, reason);
222
219
  } catch {
223
220
  /* already closing */
224
221
  }
225
222
  }
226
223
  }
227
224
 
225
+ /**
226
+ * A reconnect that ends the session lets go of its socket: a refused join
227
+ * leaves it open, and it would otherwise stay attached, and sending, after
228
+ * closed.
229
+ */
230
+ private releaseSocket(): void {
231
+ this.detachSocket('gave up');
232
+ this.failAllPending(new Error('the chat has ended'));
233
+ }
234
+
228
235
  /** Single-flight, with backoff: the same shape as the room client's. */
229
236
  private async scheduleReconnect(): Promise<void> {
230
237
  if (this.reconnecting || this.closedByUs) return;
@@ -234,6 +241,7 @@ export class ChatClient {
234
241
  this.reconnectAttempt += 1;
235
242
  if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
236
243
  this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
244
+ this.releaseSocket();
237
245
  this.closedByUs = true;
238
246
  this.emit('closed', 'gave-up');
239
247
  return;
@@ -245,8 +253,15 @@ export class ChatClient {
245
253
  await this.connect();
246
254
  return;
247
255
  } catch (err) {
256
+ // Closed while this attempt was under way, and that has been
257
+ // reported: nothing follows closed.
258
+ if (this.closedByUs) return;
248
259
  this.emit('error', err as Error);
249
- if (err instanceof ChatRpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
260
+ // A token that is wrong will be wrong next time too, and an account
261
+ // that ran out stays out until topped up: the registry's terminal
262
+ // codes, shared with StreamClient. Retrying only adds noise.
263
+ if (err instanceof ChatRpcError && isTerminalError(err.code)) {
264
+ this.releaseSocket();
250
265
  this.closedByUs = true;
251
266
  this.emit('closed', 'gave-up');
252
267
  return;
@@ -259,12 +274,20 @@ export class ChatClient {
259
274
  }
260
275
 
261
276
  close(): void {
262
- if (this.closedByUs) return;
277
+ // Whatever socket is held is released even when the session has already
278
+ // ended: one opened by a connect() after an earlier close() would
279
+ // otherwise be out of reach. `closed` is still reported once.
280
+ const wasOpen = !this.closedByUs;
263
281
  this.closedByUs = true;
282
+ this.lifecycle += 1;
264
283
  const ws = this.ws;
265
284
  this.ws = null;
266
285
  ws?.close(1000, 'client closed');
267
- this.emit('closed', 'left');
286
+ // Detached first, so the socket's own close handler no longer fails what
287
+ // was waiting on it: done here, or a join or a send in flight would wait
288
+ // out its 15 s timeout.
289
+ this.failAllPending(new Error('the chat was closed'));
290
+ if (wasOpen) this.emit('closed', 'left');
268
291
  }
269
292
 
270
293
  private rpc(method: string, params: unknown = {}): Promise<unknown> {
@@ -298,6 +321,9 @@ export class ChatClient {
298
321
  } catch {
299
322
  return;
300
323
  }
324
+ // Valid JSON that is not a message object ("null" would throw below) is
325
+ // ignored like any other frame that is not a message.
326
+ if (typeof msg !== 'object' || msg === null) return;
301
327
  if (typeof msg.id === 'number') {
302
328
  const p = this.pending.get(msg.id);
303
329
  if (!p) return;