@sentdm/voice-client 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +70 -0
- package/LICENSE +201 -0
- package/README.md +283 -2
- package/THIRD_PARTY_NOTICES.md +153 -0
- package/adapter/loader.d.mts +3 -0
- package/adapter/loader.d.mts.map +1 -0
- package/adapter/loader.d.ts +3 -0
- package/adapter/loader.d.ts.map +1 -0
- package/adapter/loader.js +6 -0
- package/adapter/loader.js.map +1 -0
- package/adapter/loader.mjs +3 -0
- package/adapter/loader.mjs.map +1 -0
- package/adapter/sinch-rtc.js +30 -0
- package/adapter/sinch-rtc.mjs +31 -0
- package/adapter/sinch.d.mts +3 -0
- package/adapter/sinch.d.mts.map +1 -0
- package/adapter/sinch.d.ts +3 -0
- package/adapter/sinch.d.ts.map +1 -0
- package/adapter/sinch.js +296 -0
- package/adapter/sinch.js.map +1 -0
- package/adapter/sinch.mjs +292 -0
- package/adapter/sinch.mjs.map +1 -0
- package/adapter/types.d.mts +79 -0
- package/adapter/types.d.mts.map +1 -0
- package/adapter/types.d.ts +79 -0
- package/adapter/types.d.ts.map +1 -0
- package/adapter/types.js +3 -0
- package/adapter/types.js.map +1 -0
- package/adapter/types.mjs +2 -0
- package/adapter/types.mjs.map +1 -0
- package/audio.d.mts +42 -0
- package/audio.d.mts.map +1 -0
- package/audio.d.ts +42 -0
- package/audio.d.ts.map +1 -0
- package/audio.js +125 -0
- package/audio.js.map +1 -0
- package/audio.mjs +121 -0
- package/audio.mjs.map +1 -0
- package/call.d.mts +60 -0
- package/call.d.mts.map +1 -0
- package/call.d.ts +60 -0
- package/call.d.ts.map +1 -0
- package/call.js +84 -0
- package/call.js.map +1 -0
- package/call.mjs +80 -0
- package/call.mjs.map +1 -0
- package/client.d.mts +118 -0
- package/client.d.mts.map +1 -0
- package/client.d.ts +118 -0
- package/client.d.ts.map +1 -0
- package/client.js +371 -0
- package/client.js.map +1 -0
- package/client.mjs +367 -0
- package/client.mjs.map +1 -0
- package/errors.d.mts +2 -0
- package/errors.d.mts.map +1 -0
- package/errors.d.ts +2 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +16 -0
- package/errors.js.map +1 -0
- package/errors.mjs +4 -0
- package/errors.mjs.map +1 -0
- package/index.d.mts +4 -0
- package/index.d.mts.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +18 -0
- package/index.js.map +1 -0
- package/index.mjs +4 -0
- package/index.mjs.map +1 -0
- package/internal/tslib.js +75 -0
- package/internal/tslib.mjs +17 -0
- package/invite.d.mts +28 -0
- package/invite.d.mts.map +1 -0
- package/invite.d.ts +28 -0
- package/invite.d.ts.map +1 -0
- package/invite.js +64 -0
- package/invite.js.map +1 -0
- package/invite.mjs +60 -0
- package/invite.mjs.map +1 -0
- package/package.json +41 -3
- package/src/adapter/loader.ts +4 -0
- package/src/adapter/sinch-rtc.ts +3 -0
- package/src/adapter/sinch.ts +348 -0
- package/src/adapter/types.ts +64 -0
- package/src/audio.ts +174 -0
- package/src/call.ts +145 -0
- package/src/client.ts +598 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +14 -0
- package/src/invite.ts +75 -0
- package/src/sw.js +14 -0
- package/src/tsconfig.json +11 -0
- package/src/version.ts +1 -0
- package/sw.js +14 -0
- package/version.d.mts +2 -0
- package/version.d.mts.map +1 -0
- package/version.d.ts +2 -0
- package/version.d.ts.map +1 -0
- package/version.js +5 -0
- package/version.js.map +1 -0
- package/version.mjs +2 -0
- package/version.mjs.map +1 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
|
|
2
|
+
import { loadAdapter } from './adapter/loader';
|
|
3
|
+
import type { AdapterOptions, CallEvent, IncomingCall, ProviderAdapter } from './adapter/types';
|
|
4
|
+
import { AudioController, type AudioLifecycle } from './audio';
|
|
5
|
+
import {
|
|
6
|
+
Call,
|
|
7
|
+
type Address,
|
|
8
|
+
type CallState,
|
|
9
|
+
type CallStats,
|
|
10
|
+
type DisconnectInfo,
|
|
11
|
+
type QualityWarning,
|
|
12
|
+
} from './call';
|
|
13
|
+
import {
|
|
14
|
+
NotRegisteredError,
|
|
15
|
+
SentVoiceError,
|
|
16
|
+
TokenExpiredError,
|
|
17
|
+
TypedEmitter,
|
|
18
|
+
createLog,
|
|
19
|
+
parseLogLevel,
|
|
20
|
+
type Log,
|
|
21
|
+
type Logger,
|
|
22
|
+
type LogLevel,
|
|
23
|
+
Telemetry,
|
|
24
|
+
fetchToken,
|
|
25
|
+
offlineRetryDelay,
|
|
26
|
+
qualify,
|
|
27
|
+
refreshDelay,
|
|
28
|
+
retryDelay,
|
|
29
|
+
unqualify,
|
|
30
|
+
type VoiceToken,
|
|
31
|
+
isE164,
|
|
32
|
+
} from '@sentdm/voice-client-core';
|
|
33
|
+
import { CallInvite, type CancelInfo } from './invite';
|
|
34
|
+
import { VERSION } from './version';
|
|
35
|
+
|
|
36
|
+
export interface SentVoiceOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Returns a voice token from your backend, which mints it with `POST /v3/voice/tokens`.
|
|
39
|
+
* Called by `register()` and again before each token expires. The token is opaque: pass it on
|
|
40
|
+
* unchanged.
|
|
41
|
+
*/
|
|
42
|
+
tokenProvider: () => Promise<string>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Sent API the SDK calls with the voice token, to exchange it when registering and to report
|
|
46
|
+
* telemetry.
|
|
47
|
+
*
|
|
48
|
+
* @default 'https://api.sent.dm'
|
|
49
|
+
*/
|
|
50
|
+
baseURL?: string | undefined;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The maximum number of times that registering or refreshing the voice token is retried
|
|
54
|
+
* in case of a temporary failure, like a network error.
|
|
55
|
+
*
|
|
56
|
+
* @default 2
|
|
57
|
+
*/
|
|
58
|
+
registerRetries?: number | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set the log level.
|
|
62
|
+
*
|
|
63
|
+
* Defaults to 'warn'.
|
|
64
|
+
*/
|
|
65
|
+
logLevel?: LogLevel | undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Set the logger. Voice tokens are redacted from every message.
|
|
69
|
+
*
|
|
70
|
+
* Defaults to globalThis.console.
|
|
71
|
+
*/
|
|
72
|
+
logger?: Logger | undefined;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where your app serves the service worker that delivers incoming calls, a copy of
|
|
76
|
+
* `@sentdm/voice-client/sw.js`. `url` is resolved against the page and defaults to `sw.js`;
|
|
77
|
+
* `scope` defaults to the worker's folder.
|
|
78
|
+
*/
|
|
79
|
+
serviceWorker?: { url?: string | undefined; scope?: string | undefined } | undefined;
|
|
80
|
+
|
|
81
|
+
audio?:
|
|
82
|
+
| {
|
|
83
|
+
/**
|
|
84
|
+
* The microphone to start with, a `deviceId` from `client.audio.inputDevices()`. The default
|
|
85
|
+
* one is used while the device is missing.
|
|
86
|
+
*/
|
|
87
|
+
inputDeviceId?: string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* The speaker to start with, a `deviceId` from `client.audio.outputDevices()`. The default
|
|
90
|
+
* one is used when the device cannot be chosen.
|
|
91
|
+
*/
|
|
92
|
+
outputDeviceId?: string | undefined;
|
|
93
|
+
/** Plays the other party's audio through this element instead of one the SDK creates. */
|
|
94
|
+
element?: HTMLAudioElement | undefined;
|
|
95
|
+
}
|
|
96
|
+
| undefined;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Usage and call quality data the SDK reports to Sent, authenticated with the voice token: the
|
|
100
|
+
* browser and device, registration and call timings, call quality and error codes. Set `disabled`
|
|
101
|
+
* to report nothing.
|
|
102
|
+
*/
|
|
103
|
+
telemetry?: { disabled?: boolean | undefined } | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface SentVoiceEvents {
|
|
107
|
+
registering: () => void;
|
|
108
|
+
registered: () => void;
|
|
109
|
+
unregistered: () => void;
|
|
110
|
+
incomingCall: (invite: CallInvite) => void;
|
|
111
|
+
activeCallChanged: (call: Call | null) => void;
|
|
112
|
+
offline: (reason: SentVoiceError) => void;
|
|
113
|
+
tokenWillExpire: (info: { expiresAt: number }) => void;
|
|
114
|
+
error: (error: SentVoiceError) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const destroyedError = () => new NotRegisteredError({ message: 'The client was destroyed.' });
|
|
118
|
+
|
|
119
|
+
const invalidAddress = (message: string) =>
|
|
120
|
+
new SentVoiceError({ code: 'INVALID_ADDRESS', category: 'validation', retriable: false, message });
|
|
121
|
+
|
|
122
|
+
const callInProgress = () =>
|
|
123
|
+
new SentVoiceError({
|
|
124
|
+
code: 'CALL_IN_PROGRESS',
|
|
125
|
+
category: 'validation',
|
|
126
|
+
retriable: false,
|
|
127
|
+
message: 'A call is in progress or an incoming call is waiting; end or decline it first.',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const namePattern = /^[A-Za-z0-9_-]+$/;
|
|
131
|
+
const identityMaxLength = 200;
|
|
132
|
+
const roomMaxLength = 27;
|
|
133
|
+
|
|
134
|
+
const isName = (value: string, maxLength: number) => value.length <= maxLength && namePattern.test(value);
|
|
135
|
+
|
|
136
|
+
export class SentVoice extends TypedEmitter<SentVoiceEvents> {
|
|
137
|
+
static readonly version: string = VERSION;
|
|
138
|
+
|
|
139
|
+
readonly audio: AudioController;
|
|
140
|
+
#tokenProvider: () => Promise<string>;
|
|
141
|
+
#baseURL: string | undefined;
|
|
142
|
+
#registerRetries: number;
|
|
143
|
+
#log: Log;
|
|
144
|
+
#adapterOptions: AdapterOptions;
|
|
145
|
+
#state: SentVoice.ClientState = 'unregistered';
|
|
146
|
+
#token: VoiceToken | undefined;
|
|
147
|
+
#adapter: Promise<ProviderAdapter> | undefined;
|
|
148
|
+
#registeredAdapter: ProviderAdapter | undefined;
|
|
149
|
+
#adapterCalls: Promise<void> = Promise.resolve();
|
|
150
|
+
#registering: Promise<void> | undefined;
|
|
151
|
+
#run = 0;
|
|
152
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
#wake: (() => void) | undefined;
|
|
154
|
+
#updates = new Map<string, (state: Exclude<CallState, 'initiated'>, error?: SentVoiceError) => void>();
|
|
155
|
+
#warnings = new Map<string, (warning: QualityWarning) => void>();
|
|
156
|
+
#invites = new Map<string, CallInvite>();
|
|
157
|
+
#calls: Call[] = [];
|
|
158
|
+
#activeCall: Call | null = null;
|
|
159
|
+
#audioLifecycle: AudioLifecycle | undefined;
|
|
160
|
+
#telemetry: Telemetry | undefined;
|
|
161
|
+
|
|
162
|
+
constructor({
|
|
163
|
+
tokenProvider,
|
|
164
|
+
baseURL,
|
|
165
|
+
registerRetries = 2,
|
|
166
|
+
logLevel,
|
|
167
|
+
logger = console,
|
|
168
|
+
serviceWorker,
|
|
169
|
+
audio,
|
|
170
|
+
telemetry,
|
|
171
|
+
}: SentVoiceOptions) {
|
|
172
|
+
super();
|
|
173
|
+
this.#tokenProvider = tokenProvider;
|
|
174
|
+
this.#baseURL = baseURL;
|
|
175
|
+
this.#registerRetries = registerRetries;
|
|
176
|
+
this.#log = createLog(
|
|
177
|
+
logger,
|
|
178
|
+
parseLogLevel(logLevel, 'SentVoiceOptions.logLevel', createLog(logger, 'warn')) ?? 'warn',
|
|
179
|
+
);
|
|
180
|
+
this.#adapterOptions = { serviceWorker, audioElement: audio?.element };
|
|
181
|
+
this.audio = new AudioController(this.#log, audio?.inputDeviceId, audio?.outputDeviceId, {
|
|
182
|
+
check: () => {
|
|
183
|
+
if (this.#state === 'destroyed') throw destroyedError();
|
|
184
|
+
},
|
|
185
|
+
onLifecycle: (lifecycle) => (this.#audioLifecycle = lifecycle),
|
|
186
|
+
});
|
|
187
|
+
if (!telemetry?.disabled) this.#telemetry = new Telemetry(baseURL, this.#log, VERSION);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get state(): SentVoice.ClientState {
|
|
191
|
+
return this.#state;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get identity(): string | undefined {
|
|
195
|
+
return this.#token?.identity;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get number(): string | undefined {
|
|
199
|
+
return this.#token?.number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get calls(): Call[] {
|
|
203
|
+
return [...this.#calls];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get activeCall(): Call | null {
|
|
207
|
+
return this.#activeCall;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get isBusy(): boolean {
|
|
211
|
+
return this.#activeCall !== null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Fetches a token from `tokenProvider`, registers with it and keeps it refreshed. */
|
|
215
|
+
async register(): Promise<void> {
|
|
216
|
+
if (this.#state === 'destroyed') throw destroyedError();
|
|
217
|
+
if (this.#state === 'registered') return;
|
|
218
|
+
if (this.#registering) return this.#registering;
|
|
219
|
+
|
|
220
|
+
const offline = this.#state === 'offline';
|
|
221
|
+
const run = this.#cancel();
|
|
222
|
+
if (!offline) this.#state = 'registering';
|
|
223
|
+
const registering = (this.#registering = this.#register(run, offline));
|
|
224
|
+
if (!offline) this.emit('registering');
|
|
225
|
+
return registering;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Goes offline and stops receiving calls. */
|
|
229
|
+
async unregister(): Promise<void> {
|
|
230
|
+
if (this.#state === 'destroyed') throw destroyedError();
|
|
231
|
+
if (this.#state === 'unregistered') return;
|
|
232
|
+
|
|
233
|
+
const started = Date.now();
|
|
234
|
+
this.#stop('unregistered');
|
|
235
|
+
const unregistered = this.#unregisterAdapter();
|
|
236
|
+
this.emit('unregistered');
|
|
237
|
+
await unregistered;
|
|
238
|
+
this.#telemetry?.unregistered(Date.now() - started);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Calls a phone number in E.164 format or another user of your app. */
|
|
242
|
+
async connect({ to }: SentVoice.ConnectParams): Promise<Call> {
|
|
243
|
+
if (to.startsWith('+')) {
|
|
244
|
+
if (!isE164(to)) {
|
|
245
|
+
throw invalidAddress(`'to' must be a phone number in E.164 format (e.g. +14155551234).`);
|
|
246
|
+
}
|
|
247
|
+
return this.#place({ kind: 'number', number: to }, (adapter) =>
|
|
248
|
+
adapter.call({ kind: 'number', number: to }),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!isName(to, identityMaxLength)) {
|
|
253
|
+
throw invalidAddress(
|
|
254
|
+
`'to' may only contain letters, digits, '-' and '_', up to ${identityMaxLength} characters.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return this.#place({ kind: 'user', identity: to }, (adapter, prefix) =>
|
|
258
|
+
adapter.call({ kind: 'user', id: qualify(prefix, to) }),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Joins one of your account's conference rooms. */
|
|
263
|
+
async joinConference({ name }: SentVoice.JoinConferenceParams): Promise<Call> {
|
|
264
|
+
if (!isName(name, roomMaxLength)) {
|
|
265
|
+
throw invalidAddress(
|
|
266
|
+
`'name' may only contain letters, digits, '-' and '_', up to ${roomMaxLength} characters.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return this.#place({ kind: 'conference', name }, (adapter, prefix) =>
|
|
270
|
+
adapter.joinConference(qualify(prefix, name)),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Ends every call and tears the client down for good; later calls throw `NotRegisteredError`. */
|
|
275
|
+
async destroy(): Promise<void> {
|
|
276
|
+
if (this.#state === 'destroyed') return;
|
|
277
|
+
|
|
278
|
+
this.#stop('destroyed');
|
|
279
|
+
this.#audioLifecycle?.destroyed();
|
|
280
|
+
this.#telemetry?.destroyed();
|
|
281
|
+
const endings = [
|
|
282
|
+
...[...this.#invites.values()].map((invite) => invite.reject()),
|
|
283
|
+
...this.#calls.map((call) => call.disconnect()),
|
|
284
|
+
];
|
|
285
|
+
await Promise.all(
|
|
286
|
+
endings.map((ending) =>
|
|
287
|
+
ending.catch((error: unknown) => this.#log.warn(`Ending a call on destroy failed: ${error}`)),
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
await this.#unregisterAdapter().catch((error: unknown) =>
|
|
291
|
+
this.#log.warn(`Unregistering on destroy failed: ${error}`),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async #register(run: number, offline: boolean): Promise<void> {
|
|
296
|
+
const started = Date.now();
|
|
297
|
+
let attempts = 0;
|
|
298
|
+
let token: VoiceToken;
|
|
299
|
+
try {
|
|
300
|
+
token = await this.#connect(run, () => attempts++);
|
|
301
|
+
this.#check(run);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.#telemetry?.registerFailed(Date.now() - started, attempts, error);
|
|
304
|
+
if (run === this.#run) {
|
|
305
|
+
this.#registering = undefined;
|
|
306
|
+
if (offline) {
|
|
307
|
+
this.#scheduleOfflineRetry(run);
|
|
308
|
+
} else {
|
|
309
|
+
this.#state = 'unregistered';
|
|
310
|
+
this.emit('unregistered');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
this.#registering = undefined;
|
|
316
|
+
this.#telemetry?.registered(Date.now() - started, attempts);
|
|
317
|
+
this.#online(token, run);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async #refresh(run: number, expiresAt: number): Promise<void> {
|
|
321
|
+
this.emit('tokenWillExpire', { expiresAt });
|
|
322
|
+
try {
|
|
323
|
+
const token = await this.#connect(run);
|
|
324
|
+
this.#check(run);
|
|
325
|
+
this.#online(token, run);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (run === this.#run) this.#offline(run, error as SentVoiceError);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async #retryOffline(run: number): Promise<void> {
|
|
332
|
+
try {
|
|
333
|
+
const token = await this.#attempt(run);
|
|
334
|
+
this.#check(run);
|
|
335
|
+
this.#online(token, run);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (run !== this.#run) return;
|
|
338
|
+
this.#log.info(`Registration retry failed: ${error}`);
|
|
339
|
+
this.#scheduleOfflineRetry(run);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async #connect(run: number, onAttempt?: () => void): Promise<VoiceToken> {
|
|
344
|
+
for (let retry = 0; ; retry++) {
|
|
345
|
+
onAttempt?.();
|
|
346
|
+
try {
|
|
347
|
+
return await this.#attempt(run);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
this.#check(run);
|
|
350
|
+
if (!(error instanceof SentVoiceError) || !error.retriable || retry >= this.#registerRetries) {
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const delay = retryDelay(retry);
|
|
355
|
+
this.#log.info(
|
|
356
|
+
`Registration attempt failed (${error}), retrying in ${Math.round(delay)} ms, ${
|
|
357
|
+
this.#registerRetries - retry
|
|
358
|
+
} attempts remaining`,
|
|
359
|
+
);
|
|
360
|
+
await this.#sleep(delay);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async #attempt(run: number): Promise<VoiceToken> {
|
|
366
|
+
this.#check(run);
|
|
367
|
+
// The provider loads while the token is exchanged, as neither needs the other. A load that fails
|
|
368
|
+
// first is raised below, after the exchange.
|
|
369
|
+
const loading = (this.#adapter ??= this.#loadAdapter());
|
|
370
|
+
loading.catch(() => {});
|
|
371
|
+
const token = await fetchToken(this.#tokenProvider, this.#baseURL);
|
|
372
|
+
await this.#callAdapter(async () => {
|
|
373
|
+
const adapter = await loading;
|
|
374
|
+
this.#check(run);
|
|
375
|
+
await adapter.register(token.credential);
|
|
376
|
+
this.#registeredAdapter = adapter;
|
|
377
|
+
});
|
|
378
|
+
return token;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async #loadAdapter(): Promise<ProviderAdapter> {
|
|
382
|
+
const adapter = await loadAdapter(this.#adapterOptions).catch((error: unknown) => {
|
|
383
|
+
this.#adapter = undefined;
|
|
384
|
+
throw error;
|
|
385
|
+
});
|
|
386
|
+
adapter.onIncoming((incoming) => this.#onIncoming(adapter, incoming));
|
|
387
|
+
adapter.onCallEvent((event) => this.#onCallEvent(event));
|
|
388
|
+
this.#audioLifecycle?.loaded(adapter);
|
|
389
|
+
return adapter;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async #place(
|
|
393
|
+
to: Address,
|
|
394
|
+
dial: (adapter: ProviderAdapter, prefix: string) => Promise<string>,
|
|
395
|
+
): Promise<Call> {
|
|
396
|
+
const { adapter, token } = this.#session();
|
|
397
|
+
if (this.#busy()) throw callInProgress();
|
|
398
|
+
const callId = await this.#reported(dial(adapter, token.prefix));
|
|
399
|
+
if (this.#state === 'destroyed') {
|
|
400
|
+
await adapter
|
|
401
|
+
.hangup(callId)
|
|
402
|
+
.catch((error: unknown) => this.#log.warn(`Ending a call on destroy failed: ${error}`));
|
|
403
|
+
throw destroyedError();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const call = this.#createCall(
|
|
407
|
+
adapter,
|
|
408
|
+
callId,
|
|
409
|
+
'outbound',
|
|
410
|
+
{ kind: 'user', identity: token.identity },
|
|
411
|
+
to,
|
|
412
|
+
);
|
|
413
|
+
this.#track(call);
|
|
414
|
+
return call;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#session(): { adapter: ProviderAdapter; token: VoiceToken } {
|
|
418
|
+
const adapter = this.#registeredAdapter;
|
|
419
|
+
const token = this.#token;
|
|
420
|
+
if (this.#state !== 'registered' || !adapter || !token) {
|
|
421
|
+
throw this.#state === 'destroyed' ? destroyedError() : new NotRegisteredError();
|
|
422
|
+
}
|
|
423
|
+
return { adapter, token };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#busy(): boolean {
|
|
427
|
+
return this.#calls.length > 0 || [...this.#invites.values()].some((invite) => invite.state === 'pending');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#onIncoming(adapter: ProviderAdapter, { callId, from }: IncomingCall): void {
|
|
431
|
+
const token = this.#token;
|
|
432
|
+
if (!token) return;
|
|
433
|
+
|
|
434
|
+
const identity = unqualify(token.prefix, from);
|
|
435
|
+
const caller: Address =
|
|
436
|
+
identity === undefined ? { kind: 'number', number: from } : { kind: 'user', identity };
|
|
437
|
+
const call = this.#createCall(adapter, callId, 'inbound', caller, {
|
|
438
|
+
kind: 'user',
|
|
439
|
+
identity: token.identity,
|
|
440
|
+
});
|
|
441
|
+
const invite = new CallInvite(call, {
|
|
442
|
+
answer: () => this.#reported(adapter.answer(callId), callId),
|
|
443
|
+
reject: () => adapter.reject(callId),
|
|
444
|
+
accepted: () => this.#track(call),
|
|
445
|
+
});
|
|
446
|
+
this.#invites.set(callId, invite);
|
|
447
|
+
this.emit('incomingCall', invite);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#onCallEvent(event: CallEvent): void {
|
|
451
|
+
if (event.type === 'qualityWarning') {
|
|
452
|
+
this.#warnings.get(event.callId)?.(event.warning);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const update = this.#updates.get(event.callId);
|
|
456
|
+
if (!update) return;
|
|
457
|
+
if (event.type !== 'ended') {
|
|
458
|
+
update(event.type === 'reconnected' ? 'connected' : event.type);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.#updates.delete(event.callId);
|
|
463
|
+
this.#warnings.delete(event.callId);
|
|
464
|
+
this.#invites.delete(event.callId);
|
|
465
|
+
this.#calls = this.#calls.filter((call) => call.id !== event.callId);
|
|
466
|
+
if (this.#activeCall?.id === event.callId) {
|
|
467
|
+
this.#activeCall = null;
|
|
468
|
+
this.emit('activeCallChanged', null);
|
|
469
|
+
}
|
|
470
|
+
update(event.reason, event.error);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#createCall(
|
|
474
|
+
adapter: ProviderAdapter,
|
|
475
|
+
callId: string,
|
|
476
|
+
direction: 'inbound' | 'outbound',
|
|
477
|
+
from: Address,
|
|
478
|
+
to: Address,
|
|
479
|
+
): Call {
|
|
480
|
+
this.#audioLifecycle?.callStarted();
|
|
481
|
+
const call = new Call(callId, direction, from, to, {
|
|
482
|
+
hangup: () => adapter.hangup(callId),
|
|
483
|
+
mute: (muted) => adapter.mute(callId, muted),
|
|
484
|
+
sendDigits: (digits) => adapter.sendDigits(callId, digits),
|
|
485
|
+
getStats: () => adapter.getStats(callId),
|
|
486
|
+
onUpdate: (update) => this.#updates.set(callId, update),
|
|
487
|
+
onQualityWarning: (warn) => this.#warnings.set(callId, warn),
|
|
488
|
+
});
|
|
489
|
+
this.#telemetry?.callStarted(call);
|
|
490
|
+
return call;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#reported<T>(operation: Promise<T>, callId?: string): Promise<T> {
|
|
494
|
+
return operation.catch((error: unknown) => {
|
|
495
|
+
this.#telemetry?.error(error, callId);
|
|
496
|
+
throw error;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#track(call: Call): void {
|
|
501
|
+
this.#calls = [...this.#calls, call];
|
|
502
|
+
this.#activeCall = call;
|
|
503
|
+
this.emit('activeCallChanged', call);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#online(token: VoiceToken, run: number): void {
|
|
507
|
+
this.#token = token;
|
|
508
|
+
this.#telemetry?.useToken(token.token);
|
|
509
|
+
const delay = refreshDelay(token);
|
|
510
|
+
this.#log.debug(`Refreshing the voice token in ${delay} ms`);
|
|
511
|
+
this.#timer = setTimeout(() => void this.#refresh(run, token.expiresAt), delay);
|
|
512
|
+
if (this.#state === 'registered') return;
|
|
513
|
+
|
|
514
|
+
this.#state = 'registered';
|
|
515
|
+
this.emit('registered');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#offline(run: number, error: SentVoiceError): void {
|
|
519
|
+
this.#log.warn(`Token refresh failed, going offline: ${error}`);
|
|
520
|
+
this.#state = 'offline';
|
|
521
|
+
this.#scheduleOfflineRetry(run);
|
|
522
|
+
this.#telemetry?.error(error);
|
|
523
|
+
this.emit('error', error);
|
|
524
|
+
if (run === this.#run) this.emit('offline', new TokenExpiredError());
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#scheduleOfflineRetry(run: number): void {
|
|
528
|
+
this.#timer = setTimeout(() => void this.#retryOffline(run), offlineRetryDelay());
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#unregisterAdapter(): Promise<void> {
|
|
532
|
+
return this.#callAdapter(async () => {
|
|
533
|
+
const adapter = this.#registeredAdapter;
|
|
534
|
+
this.#registeredAdapter = undefined;
|
|
535
|
+
await adapter?.unregister();
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#callAdapter(call: () => Promise<void>): Promise<void> {
|
|
540
|
+
const result = this.#adapterCalls.then(call);
|
|
541
|
+
this.#adapterCalls = result.catch(() => {});
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#sleep(ms: number): Promise<void> {
|
|
546
|
+
return new Promise((resolve) => {
|
|
547
|
+
this.#wake = resolve;
|
|
548
|
+
this.#timer = setTimeout(resolve, ms);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#stop(state: 'unregistered' | 'destroyed'): void {
|
|
553
|
+
this.#cancel();
|
|
554
|
+
this.#state = state;
|
|
555
|
+
this.#token = undefined;
|
|
556
|
+
this.#registering = undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
#cancel(): number {
|
|
560
|
+
clearTimeout(this.#timer);
|
|
561
|
+
this.#wake?.();
|
|
562
|
+
this.#timer = undefined;
|
|
563
|
+
this.#wake = undefined;
|
|
564
|
+
return ++this.#run;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#check(run: number): void {
|
|
568
|
+
if (run === this.#run) return;
|
|
569
|
+
throw this.#state === 'destroyed' ? destroyedError() : new NotRegisteredError();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export declare namespace SentVoice {
|
|
574
|
+
export type ClientState = 'unregistered' | 'registering' | 'registered' | 'offline' | 'destroyed';
|
|
575
|
+
|
|
576
|
+
export interface ConnectParams {
|
|
577
|
+
/** A phone number in E.164 format like `+14155551234`, or the identity of another user of your app. */
|
|
578
|
+
to: string;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export interface JoinConferenceParams {
|
|
582
|
+
/** The room name, private to your account: letters, digits, `-` and `_`, up to 27 characters. */
|
|
583
|
+
name: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export {
|
|
587
|
+
type Call as Call,
|
|
588
|
+
type CallState as CallState,
|
|
589
|
+
type Address as Address,
|
|
590
|
+
type CallStats as CallStats,
|
|
591
|
+
type DisconnectInfo as DisconnectInfo,
|
|
592
|
+
type QualityWarning as QualityWarning,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
export { type CallInvite as CallInvite, type CancelInfo as CancelInfo };
|
|
596
|
+
|
|
597
|
+
export { type AudioController as AudioController };
|
|
598
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The error family lives in @sentdm/voice-client-core; this keeps @sentdm/voice-client/errors as its public
|
|
2
|
+
// entry point.
|
|
3
|
+
export {
|
|
4
|
+
SentVoiceError,
|
|
5
|
+
TokenExpiredError,
|
|
6
|
+
TokenInvalidError,
|
|
7
|
+
NotRegisteredError,
|
|
8
|
+
MediaPermissionError,
|
|
9
|
+
CallRejectedError,
|
|
10
|
+
CallFailedError,
|
|
11
|
+
NetworkError,
|
|
12
|
+
CapabilityUnsupportedError,
|
|
13
|
+
type SentVoiceErrorCode,
|
|
14
|
+
type SentVoiceErrorCategory,
|
|
15
|
+
} from '@sentdm/voice-client-core';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { SentVoice as default } from './client';
|
|
2
|
+
|
|
3
|
+
export { SentVoice, type SentVoiceOptions } from './client';
|
|
4
|
+
export {
|
|
5
|
+
SentVoiceError,
|
|
6
|
+
TokenExpiredError,
|
|
7
|
+
TokenInvalidError,
|
|
8
|
+
NotRegisteredError,
|
|
9
|
+
MediaPermissionError,
|
|
10
|
+
CallRejectedError,
|
|
11
|
+
CallFailedError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
CapabilityUnsupportedError,
|
|
14
|
+
} from './errors';
|
package/src/invite.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Address, Call } from './call';
|
|
2
|
+
import { CallFailedError, type SentVoiceError, TypedEmitter } from '@sentdm/voice-client-core';
|
|
3
|
+
|
|
4
|
+
export interface CancelInfo {
|
|
5
|
+
error?: SentVoiceError;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CallInviteEvents {
|
|
9
|
+
accepted: (call: Call) => void;
|
|
10
|
+
rejected: () => void;
|
|
11
|
+
cancelled: (info: CancelInfo) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface InviteProvider {
|
|
15
|
+
answer(): Promise<void>;
|
|
16
|
+
reject(): Promise<void>;
|
|
17
|
+
accepted(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const endedError = () => new CallFailedError({ message: 'The call ended before it was answered.' });
|
|
21
|
+
|
|
22
|
+
export class CallInvite extends TypedEmitter<CallInviteEvents> {
|
|
23
|
+
readonly from: Address;
|
|
24
|
+
readonly to: Address;
|
|
25
|
+
#call: Call;
|
|
26
|
+
#provider: InviteProvider;
|
|
27
|
+
#state: 'pending' | 'accepted' | 'rejected' | 'cancelled' = 'pending';
|
|
28
|
+
#accepting: Promise<Call> | undefined;
|
|
29
|
+
|
|
30
|
+
constructor(call: Call, provider: InviteProvider) {
|
|
31
|
+
super();
|
|
32
|
+
this.from = call.from;
|
|
33
|
+
this.to = call.to;
|
|
34
|
+
this.#call = call;
|
|
35
|
+
this.#provider = provider;
|
|
36
|
+
call.on('disconnected', ({ error }) => {
|
|
37
|
+
if (this.#state !== 'pending') return;
|
|
38
|
+
this.#state = 'cancelled';
|
|
39
|
+
this.emit('cancelled', error ? { error } : {});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get state(): 'pending' | 'accepted' | 'rejected' | 'cancelled' {
|
|
44
|
+
return this.#state;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Answers the call, asking for microphone permission if it was not granted yet. */
|
|
48
|
+
async accept(): Promise<Call> {
|
|
49
|
+
if (this.#state === 'accepted') return this.#call;
|
|
50
|
+
if (this.#state !== 'pending') throw endedError();
|
|
51
|
+
this.#accepting ??= this.#accept().finally(() => (this.#accepting = undefined));
|
|
52
|
+
return this.#accepting;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Declines the call; does nothing unless it is still pending. */
|
|
56
|
+
async reject(): Promise<void> {
|
|
57
|
+
if (this.#state !== 'pending') return;
|
|
58
|
+
this.#state = 'rejected';
|
|
59
|
+
this.emit('rejected');
|
|
60
|
+
await this.#provider.reject();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async #accept(): Promise<Call> {
|
|
64
|
+
try {
|
|
65
|
+
await this.#provider.answer();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (this.#state === 'pending') throw error;
|
|
68
|
+
}
|
|
69
|
+
if (this.#state !== 'pending') throw endedError();
|
|
70
|
+
this.#state = 'accepted';
|
|
71
|
+
this.#provider.accepted();
|
|
72
|
+
this.emit('accepted', this.#call);
|
|
73
|
+
return this.#call;
|
|
74
|
+
}
|
|
75
|
+
}
|