@voltkit-rtc/client 0.6.1 → 0.7.0

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 (57) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/README.md +222 -11
  3. package/dist/cjs/chat.d.ts +0 -9
  4. package/dist/cjs/chat.d.ts.map +1 -1
  5. package/dist/cjs/chat.js +16 -14
  6. package/dist/cjs/chat.js.map +1 -1
  7. package/dist/cjs/errors.d.ts +571 -0
  8. package/dist/cjs/errors.d.ts.map +1 -0
  9. package/dist/cjs/errors.js +155 -0
  10. package/dist/cjs/errors.js.map +1 -0
  11. package/dist/cjs/index.d.ts +298 -16
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +654 -118
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/media-permissions.d.ts +101 -0
  16. package/dist/cjs/media-permissions.d.ts.map +1 -0
  17. package/dist/cjs/media-permissions.js +166 -0
  18. package/dist/cjs/media-permissions.js.map +1 -0
  19. package/dist/cjs/network.d.ts +80 -0
  20. package/dist/cjs/network.d.ts.map +1 -0
  21. package/dist/cjs/network.js +190 -0
  22. package/dist/cjs/network.js.map +1 -0
  23. package/dist/cjs/token.d.ts +17 -0
  24. package/dist/cjs/token.d.ts.map +1 -0
  25. package/dist/cjs/token.js +36 -0
  26. package/dist/cjs/token.js.map +1 -0
  27. package/dist/esm/chat.d.ts +0 -9
  28. package/dist/esm/chat.d.ts.map +1 -1
  29. package/dist/esm/chat.js +16 -14
  30. package/dist/esm/chat.js.map +1 -1
  31. package/dist/esm/errors.d.ts +571 -0
  32. package/dist/esm/errors.d.ts.map +1 -0
  33. package/dist/esm/errors.js +149 -0
  34. package/dist/esm/errors.js.map +1 -0
  35. package/dist/esm/index.d.ts +298 -16
  36. package/dist/esm/index.d.ts.map +1 -1
  37. package/dist/esm/index.js +633 -109
  38. package/dist/esm/index.js.map +1 -1
  39. package/dist/esm/media-permissions.d.ts +101 -0
  40. package/dist/esm/media-permissions.d.ts.map +1 -0
  41. package/dist/esm/media-permissions.js +160 -0
  42. package/dist/esm/media-permissions.js.map +1 -0
  43. package/dist/esm/network.d.ts +80 -0
  44. package/dist/esm/network.d.ts.map +1 -0
  45. package/dist/esm/network.js +183 -0
  46. package/dist/esm/network.js.map +1 -0
  47. package/dist/esm/token.d.ts +17 -0
  48. package/dist/esm/token.d.ts.map +1 -0
  49. package/dist/esm/token.js +31 -0
  50. package/dist/esm/token.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/chat.ts +7 -14
  53. package/src/errors.ts +162 -0
  54. package/src/index.ts +775 -109
  55. package/src/media-permissions.ts +220 -0
  56. package/src/network.ts +212 -0
  57. package/src/token.ts +31 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * What this client may publish, from the join result: the project's media
3
+ * settings (`media.permissions`), the media modes the super admin has enabled
4
+ * (`media.profiles`) and, from older servers, the `video` policy alone.
5
+ *
6
+ * The server enforces all of it whatever a client sends; the SDK follows it so
7
+ * that it never sends what would be refused, and can say why it sent less.
8
+ * Pure: no WebRTC, so it is tested with plain objects.
9
+ */
10
+ import { allowedQuality, type VideoPolicy, type VideoQuality } from './quality.js';
11
+
12
+ /** Standard (mono Opus, up to 64 kbps) or HD (stereo Opus, up to 128 kbps) microphone audio. */
13
+ export type AudioQuality = 'std' | 'hd';
14
+
15
+ export const AUDIO_QUALITIES: readonly AudioQuality[] = ['std', 'hd'];
16
+
17
+ /**
18
+ * How each audio quality is sent. Standard really stays at or under 64 kbps:
19
+ * the server bills HD on what it measures (above 72 kbps) or on a declared
20
+ * Opus maxaveragebitrate above 64,000, so a standard microphone must not
21
+ * drift into either. `maxBitrate` is the Opus maxaveragebitrate declared to
22
+ * the server and the encoder; `senderMaxBitrate` is the sender's cap, which
23
+ * holds the encoder there. Standard's sender is held a little under the
24
+ * declared 64 kbps: Opus VBR around a 64 kbps target measured up to 65 kbps
25
+ * in a 5 s window, and the cap keeps every window at or under 64.
26
+ */
27
+ export const AUDIO_SETTINGS: Readonly<Record<AudioQuality, { stereo: boolean; maxBitrate: number; senderMaxBitrate: number }>> = {
28
+ std: { stereo: false, maxBitrate: 64_000, senderMaxBitrate: 60_000 },
29
+ hd: { stereo: true, maxBitrate: 128_000, senderMaxBitrate: 128_000 },
30
+ };
31
+
32
+ /** One media profile from the join result: the encoding contract of a mode, as the server stores it. */
33
+ export interface MediaProfile {
34
+ id?: string;
35
+ kind?: 'audio' | 'video';
36
+ enabled?: boolean;
37
+ width?: number;
38
+ height?: number;
39
+ fps?: number;
40
+ maxBitrate?: number;
41
+ [field: string]: unknown;
42
+ }
43
+
44
+ /** The join result's `media` field (servers with wallet billing); absent from older servers. */
45
+ export interface JoinMedia {
46
+ /** The enabled modes the project's settings allow, by id (`audio_std`, `video_720_60`, ...). */
47
+ profiles: Record<string, MediaProfile>;
48
+ /** The project's settings, hierarchical: a maximum allows every mode at or below it. */
49
+ permissions: {
50
+ audio: boolean;
51
+ video: boolean;
52
+ maxVideoQuality: 480 | 720 | 1080;
53
+ maxFrameRate: 30 | 60;
54
+ audioQualityMax: AudioQuality;
55
+ };
56
+ /** How many videos one participant receives at most, and how many of them above 360p. */
57
+ fanout: { maxHdVideoSubscriptions: number; maxVideoSubscriptions: number };
58
+ profilesVersion?: number;
59
+ }
60
+
61
+ /** How the session is billed: flat minutes (`legacy`) or the VoltKit wallet (`wallet`). */
62
+ export type BillingMode = 'legacy' | 'wallet';
63
+
64
+ export interface JoinBilling {
65
+ mode: BillingMode;
66
+ }
67
+
68
+ /** What this client may publish right now. */
69
+ export interface MediaPermissions {
70
+ /** Microphone, music and screen audio. */
71
+ audio: boolean;
72
+ /** Camera and screen. */
73
+ video: boolean;
74
+ /** The largest video quality at 30 fps. */
75
+ maxVideoQuality: VideoQuality;
76
+ /** 60 when some 60 fps mode is allowed. */
77
+ maxFrameRate: 30 | 60;
78
+ /** The largest video quality at 60 fps; null when 60 fps is not allowed. */
79
+ maxVideoQuality60: VideoQuality | null;
80
+ audioQualityMax: AudioQuality;
81
+ /** The most videos this client receives at once; null when the server sets no limit. */
82
+ maxVideoSubscriptions: number | null;
83
+ /** Where these came from: the join result's `media`, the older `video` policy, or defaults before a join. */
84
+ source: 'media' | 'video-policy' | 'default';
85
+ }
86
+
87
+ /** Before a join nothing is known: everything is open, and the join result narrows it. */
88
+ export const DEFAULT_MEDIA_PERMISSIONS: MediaPermissions = Object.freeze({
89
+ audio: true,
90
+ video: true,
91
+ maxVideoQuality: '1080p',
92
+ maxFrameRate: 60,
93
+ maxVideoQuality60: '1080p',
94
+ audioQualityMax: 'hd',
95
+ maxVideoSubscriptions: null,
96
+ source: 'default',
97
+ });
98
+
99
+ const RANK: Record<VideoQuality, number> = { '480p': 0, '720p': 1, '1080p': 2 };
100
+ const lower = (a: VideoQuality, b: VideoQuality): VideoQuality => (RANK[a] <= RANK[b] ? a : b);
101
+
102
+ /** A video height (or a profile's shorter side) as the quality that holds it. */
103
+ function qualityOfHeight(height: unknown): VideoQuality | null {
104
+ if (typeof height !== 'number' || !Number.isFinite(height) || height <= 0) return null;
105
+ if (height >= 1080) return '1080p';
106
+ if (height >= 720) return '720p';
107
+ return '480p';
108
+ }
109
+
110
+ const PROFILE_ID = /^video_(\d+)_(\d+)$/;
111
+
112
+ /** The size and frame rate of a video profile: its own fields, else its id (`video_720_60`). */
113
+ function videoMode(id: string, p: MediaProfile): { quality: VideoQuality; fps: number } | null {
114
+ const fromId = PROFILE_ID.exec(id);
115
+ const height = typeof p.height === 'number' ? p.height : fromId ? Number(fromId[1]) : NaN;
116
+ const fps = typeof p.fps === 'number' ? p.fps : fromId ? Number(fromId[2]) : NaN;
117
+ const quality = qualityOfHeight(height);
118
+ return quality && Number.isFinite(fps) ? { quality, fps } : null;
119
+ }
120
+
121
+ function kindOf(id: string, p: MediaProfile): 'audio' | 'video' | null {
122
+ if (p.kind === 'audio' || p.kind === 'video') return p.kind;
123
+ if (id.startsWith('audio_')) return 'audio';
124
+ if (id.startsWith('video_')) return 'video';
125
+ return null;
126
+ }
127
+
128
+ /** The join result fields these come from. */
129
+ export interface PermissionSource {
130
+ video?: VideoPolicy;
131
+ media?: JoinMedia;
132
+ billing?: JoinBilling;
133
+ }
134
+
135
+ /**
136
+ * Resolve what may be published. The legacy `video` policy and `media` both
137
+ * apply (the lower wins). The profiles narrow a **wallet** session only: they
138
+ * are what its tiers bill, and the media node caps to them; a session billed
139
+ * the flat way keeps every mode its settings allow, as it always had.
140
+ */
141
+ export function resolvePermissions(join: PermissionSource | null | undefined): MediaPermissions {
142
+ if (!join) return DEFAULT_MEDIA_PERMISSIONS;
143
+ const policy = join.video;
144
+ const policyMax = allowedQuality(undefined, policy ?? { maxQuality: '1080p' });
145
+ const policyFps: 30 | 60 = policy?.maxFrameRate === 30 ? 30 : 60;
146
+ const media = join.media;
147
+ if (!media || typeof media !== 'object' || !media.permissions) {
148
+ return {
149
+ audio: true,
150
+ video: true,
151
+ maxVideoQuality: policyMax,
152
+ maxFrameRate: policyFps,
153
+ maxVideoQuality60: policyFps === 60 ? policyMax : null,
154
+ audioQualityMax: 'hd',
155
+ maxVideoSubscriptions: null,
156
+ source: policy ? 'video-policy' : 'default',
157
+ };
158
+ }
159
+
160
+ const perm = media.permissions;
161
+ let audio = perm.audio !== false;
162
+ let video = perm.video !== false;
163
+ let maxVideoQuality = lower(qualityOfHeight(perm.maxVideoQuality) ?? '1080p', policyMax);
164
+ let maxFrameRate: 30 | 60 = perm.maxFrameRate === 30 || policyFps === 30 ? 30 : 60;
165
+ let audioQualityMax: AudioQuality = perm.audioQualityMax === 'std' ? 'std' : 'hd';
166
+ let maxVideoQuality60: VideoQuality | null = maxFrameRate === 60 ? maxVideoQuality : null;
167
+ let maxVideoSubscriptions: number | null = null;
168
+
169
+ const wallet = join.billing?.mode === 'wallet';
170
+ const profiles = media.profiles && typeof media.profiles === 'object' ? media.profiles : {};
171
+ const documented = (media.profilesVersion ?? 0) > 0 || Object.keys(profiles).length > 0;
172
+ if (wallet && documented) {
173
+ // The largest enabled mode at each frame rate within the settings: what
174
+ // the media node caps a producer to. A size whose own mode is disabled is
175
+ // refused at 60 fps and capped at 30, so neither may be asked for.
176
+ const cap = maxVideoQuality;
177
+ let max30: VideoQuality | null = null;
178
+ let max60: VideoQuality | null = null;
179
+ let std = false;
180
+ let hd = false;
181
+ for (const [id, profile] of Object.entries(profiles)) {
182
+ if (!profile || profile.enabled === false) continue;
183
+ const kind = kindOf(id, profile);
184
+ if (kind === 'audio') {
185
+ if (id === 'audio_hd') hd = true;
186
+ else std = true;
187
+ } else if (kind === 'video') {
188
+ const mode = videoMode(id, profile);
189
+ if (!mode || RANK[mode.quality] > RANK[cap]) continue;
190
+ if (mode.fps > 30) max60 = max60 && RANK[max60] >= RANK[mode.quality] ? max60 : mode.quality;
191
+ else max30 = max30 && RANK[max30] >= RANK[mode.quality] ? max30 : mode.quality;
192
+ }
193
+ }
194
+ // No enabled mode of a kind is that kind switched off: the controller
195
+ // refuses it as media-disabled.
196
+ audio = audio && (std || hd);
197
+ if (!hd) audioQualityMax = 'std';
198
+ video = video && (max30 !== null || max60 !== null);
199
+ maxVideoQuality = max30 ?? max60 ?? cap;
200
+ maxVideoQuality60 = maxFrameRate === 60 ? max60 : null;
201
+ if (!maxVideoQuality60) maxFrameRate = 30;
202
+ }
203
+ if (wallet) {
204
+ const limit = media.fanout?.maxVideoSubscriptions;
205
+ maxVideoSubscriptions = typeof limit === 'number' && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : null;
206
+ }
207
+ return { audio, video, maxVideoQuality, maxFrameRate, maxVideoQuality60, audioQualityMax, maxVideoSubscriptions, source: 'media' };
208
+ }
209
+
210
+ /** The quality to send at a frame rate: the request, capped to what that frame rate allows. */
211
+ export function qualityAt(requested: VideoQuality | undefined, frameRate: 30 | 60, permissions: MediaPermissions): VideoQuality {
212
+ const max = frameRate === 60 && permissions.maxVideoQuality60 ? permissions.maxVideoQuality60 : permissions.maxVideoQuality;
213
+ return allowedQuality(requested, { maxQuality: max });
214
+ }
215
+
216
+ /** Whether an explicit request for this quality at 60 fps is above what 60 fps allows. */
217
+ export function above60fpsCap(requested: VideoQuality | undefined, permissions: MediaPermissions): boolean {
218
+ if (!requested || !(requested in RANK) || !permissions.maxVideoQuality60) return false;
219
+ return RANK[lower(requested, permissions.maxVideoQuality)] > RANK[permissions.maxVideoQuality60];
220
+ }
package/src/network.ts ADDED
@@ -0,0 +1,212 @@
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
+
12
+ export type NetworkQualityLevel = 'excellent' | 'good' | 'poor' | 'bad';
13
+
14
+ export interface NetworkQuality {
15
+ level: NetworkQualityLevel;
16
+ /** Round trip to the media server, in ms; null before the browser has measured one. */
17
+ rttMs: number | null;
18
+ /** Packets lost over the last interval, in percent, the worse of sending and receiving; null when nothing moved. */
19
+ lossPct: number | null;
20
+ /** The bandwidth the browser estimates it can use, in kbps: upload while publishing, else download when reported. */
21
+ availableKbps: number | null;
22
+ }
23
+
24
+ /** How often the transports are sampled while someone listens for `networkQuality`. */
25
+ export const NETWORK_QUALITY_INTERVAL_MS = 3000;
26
+
27
+ /**
28
+ * The thresholds: the first level whose round trip and loss are both below
29
+ * its limits, else `bad`. An unknown value does not lower the level.
30
+ */
31
+ export const NETWORK_QUALITY_LEVELS: readonly { level: Exclude<NetworkQualityLevel, 'bad'>; rttMsBelow: number; lossPctBelow: number }[] = [
32
+ { level: 'excellent', rttMsBelow: 150, lossPctBelow: 1 },
33
+ { level: 'good', rttMsBelow: 300, lossPctBelow: 3 },
34
+ { level: 'poor', rttMsBelow: 500, lossPctBelow: 8 },
35
+ ];
36
+
37
+ export function networkLevel(rttMs: number | null, lossPct: number | null): NetworkQualityLevel {
38
+ for (const l of NETWORK_QUALITY_LEVELS) {
39
+ if ((rttMs === null || rttMs < l.rttMsBelow) && (lossPct === null || lossPct < l.lossPctBelow)) return l.level;
40
+ }
41
+ return 'bad';
42
+ }
43
+
44
+ /** One transport's counters at one moment; the next sample's deltas are taken against it. */
45
+ export interface TransportSample {
46
+ /** The selected candidate pair's current round trip, in ms. */
47
+ rttMs: number | null;
48
+ /** The round trip RTCP reports from the server, in ms (a fallback). */
49
+ remoteRttMs: number | null;
50
+ /** RTP packets sent, and how many of them the server reported lost. */
51
+ packetsSent: number;
52
+ remotePacketsLost: number;
53
+ /** RTP packets received, and how many were lost on the way. */
54
+ packetsReceived: number;
55
+ packetsLost: number;
56
+ /** Bits per second the browser estimates it can send / receive. */
57
+ availableOutgoing: number | null;
58
+ availableIncoming: number | null;
59
+ }
60
+
61
+ const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
62
+ const optNum = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
63
+
64
+ /** The counters of one transport's report. */
65
+ export function readTransportStats(report: Iterable<StatsEntry>): TransportSample {
66
+ const entries = [...report].filter((e): e is StatsEntry => typeof e === 'object' && e !== null);
67
+ const byId = new Map<string, StatsEntry>();
68
+ for (const entry of entries) if (typeof entry.id === 'string') byId.set(entry.id, entry);
69
+
70
+ // The candidate pair in use: the transport's selected one, else a nominated
71
+ // succeeded pair (Firefox reports no transport entry).
72
+ let pair: StatsEntry | undefined;
73
+ for (const transport of entries.filter((e) => e.type === 'transport')) {
74
+ const selected = typeof transport.selectedCandidatePairId === 'string' ? byId.get(transport.selectedCandidatePairId) : undefined;
75
+ if (selected) { pair = selected; break; }
76
+ }
77
+ if (!pair) {
78
+ const pairs = entries.filter((e) => e.type === 'candidate-pair');
79
+ pair = pairs.find((p) => p.nominated === true && p.state === 'succeeded') ?? pairs.find((p) => p.selected === true);
80
+ }
81
+ const rtt = optNum(pair?.currentRoundTripTime);
82
+
83
+ let remoteRtt: number | undefined;
84
+ let packetsSent = 0;
85
+ let remotePacketsLost = 0;
86
+ let packetsReceived = 0;
87
+ let packetsLost = 0;
88
+ for (const e of entries) {
89
+ if (e.type === 'outbound-rtp') packetsSent += num(e.packetsSent);
90
+ else if (e.type === 'remote-inbound-rtp') {
91
+ remotePacketsLost += Math.max(0, num(e.packetsLost));
92
+ const r = optNum(e.roundTripTime);
93
+ if (r !== undefined) remoteRtt = Math.max(remoteRtt ?? 0, r);
94
+ } else if (e.type === 'inbound-rtp') {
95
+ packetsReceived += num(e.packetsReceived);
96
+ packetsLost += Math.max(0, num(e.packetsLost));
97
+ }
98
+ }
99
+ const out = optNum(pair?.availableOutgoingBitrate);
100
+ const inc = optNum(pair?.availableIncomingBitrate);
101
+ return {
102
+ rttMs: rtt === undefined ? null : Math.round(rtt * 1000),
103
+ remoteRttMs: remoteRtt === undefined ? null : Math.round(remoteRtt * 1000),
104
+ packetsSent,
105
+ remotePacketsLost,
106
+ packetsReceived,
107
+ packetsLost,
108
+ availableOutgoing: out ?? null,
109
+ availableIncoming: inc ?? null,
110
+ };
111
+ }
112
+
113
+ const pct = (lost: number, total: number): number | null => (total > 0 ? Math.round((Math.min(lost, total) / total) * 1000) / 10 : null);
114
+
115
+ /**
116
+ * The quality now, from each transport's sample and the one before it (by
117
+ * key; a transport that is new, or whose counters went backwards, gives no
118
+ * loss for this interval). `publishing` picks the upload estimate.
119
+ */
120
+ export function networkQualityOf(
121
+ current: ReadonlyMap<string, TransportSample>,
122
+ previous: ReadonlyMap<string, TransportSample>,
123
+ publishing: boolean,
124
+ ): NetworkQuality {
125
+ let rttMs: number | null = null;
126
+ let lossPct: number | null = null;
127
+ let upload: number | null = null;
128
+ let download: number | null = null;
129
+ for (const [key, now] of current) {
130
+ const rtt = now.rttMs ?? now.remoteRttMs;
131
+ if (rtt !== null) rttMs = Math.max(rttMs ?? 0, rtt);
132
+ if (now.availableOutgoing !== null) upload = Math.max(upload ?? 0, now.availableOutgoing);
133
+ if (now.availableIncoming !== null) download = Math.max(download ?? 0, now.availableIncoming);
134
+ const before = previous.get(key);
135
+ if (!before) continue;
136
+ const sent = now.packetsSent - before.packetsSent;
137
+ const remoteLost = now.remotePacketsLost - before.remotePacketsLost;
138
+ const received = now.packetsReceived - before.packetsReceived;
139
+ const lost = now.packetsLost - before.packetsLost;
140
+ // Counters only go forward on one transport; backwards is a new one.
141
+ const up = sent >= 0 && remoteLost >= 0 ? pct(remoteLost, sent) : null;
142
+ const down = received >= 0 && lost >= 0 ? pct(lost, received + lost) : null;
143
+ for (const value of [up, down]) if (value !== null) lossPct = Math.max(lossPct ?? 0, value);
144
+ }
145
+ const bandwidth = publishing ? upload ?? download : download ?? upload;
146
+ return {
147
+ level: networkLevel(rttMs, lossPct),
148
+ rttMs,
149
+ lossPct,
150
+ availableKbps: bandwidth === null ? null : Math.round(bandwidth / 1000),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Samples the transports on a timer while wanted and reports each interval.
156
+ * The first report comes as soon as a round trip is known; loss needs the
157
+ * previous interval's counters, so it is null until the second.
158
+ */
159
+ export class NetworkQualityWatch {
160
+ private timer: ReturnType<typeof setInterval> | null = null;
161
+ private previous = new Map<string, TransportSample>();
162
+ private sampling = false;
163
+ private generation = 0;
164
+
165
+ constructor(
166
+ /** Each live transport's report, by a key that changes when the transport does. */
167
+ private readonly sample: () => Promise<Map<string, Iterable<StatsEntry>>>,
168
+ private readonly publishing: () => boolean,
169
+ private readonly report: (quality: NetworkQuality) => void,
170
+ private readonly intervalMs = NETWORK_QUALITY_INTERVAL_MS,
171
+ ) {}
172
+
173
+ get running(): boolean {
174
+ return this.timer !== null;
175
+ }
176
+
177
+ /** Start or stop sampling. Safe to call as often as anything changes. */
178
+ set(wanted: boolean): void {
179
+ if (wanted && !this.timer) {
180
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
181
+ void this.tick();
182
+ } else if (!wanted && this.timer) {
183
+ clearInterval(this.timer);
184
+ this.timer = null;
185
+ this.generation += 1;
186
+ this.previous = new Map();
187
+ }
188
+ }
189
+
190
+ private async tick(): Promise<void> {
191
+ if (this.sampling) return;
192
+ const generation = this.generation;
193
+ this.sampling = true;
194
+ try {
195
+ const reports = await this.sample();
196
+ if (generation !== this.generation || !this.timer) return;
197
+ const current = new Map<string, TransportSample>();
198
+ for (const [key, report] of reports) current.set(key, readTransportStats(report));
199
+ const first = this.previous.size === 0;
200
+ const quality = networkQualityOf(current, this.previous, this.publishing());
201
+ this.previous = current;
202
+ // Nothing to report without a transport, or before a second sample
203
+ // when not even the round trip is known yet.
204
+ if (current.size === 0 || (first && quality.rttMs === null)) return;
205
+ this.report(quality);
206
+ } catch {
207
+ // Statistics are best effort; the next interval tries again.
208
+ } finally {
209
+ this.sampling = false;
210
+ }
211
+ }
212
+ }
package/src/token.ts ADDED
@@ -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
+
7
+ /** How long before the token's expiry `tokenExpiring` fires. */
8
+ export const TOKEN_EXPIRING_LEAD_MS = 60_000;
9
+
10
+ /**
11
+ * The socket URL with the token as its `token` query parameter. Joined by
12
+ * hand rather than with `URL.searchParams`: React Native's `URL` is a partial
13
+ * polyfill (older releases had no `searchParams.set`), and a WebSocket URL has
14
+ * no fragment, so the string form is the same as the URL object's.
15
+ */
16
+ export function socketUrl(wsUrl: string, token: string): string {
17
+ return `${wsUrl}${wsUrl.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
18
+ }
19
+
20
+ /** The `exp` claim of a JWT, in unix milliseconds; null for a token that is not a readable JWT. */
21
+ export function tokenExpiresAt(token: string): number | null {
22
+ const payload = typeof token === 'string' ? token.split('.')[1] : undefined;
23
+ if (!payload || typeof globalThis.atob !== 'function') return null;
24
+ try {
25
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
26
+ const json = JSON.parse(globalThis.atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))) as { exp?: unknown };
27
+ return typeof json.exp === 'number' && Number.isFinite(json.exp) && json.exp > 0 ? json.exp * 1000 : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }