@agatx/serenada-core 0.6.10
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/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SerenadaConfig,
|
|
3
|
+
DiagnosticsReport,
|
|
4
|
+
DiagnosticCheckResult,
|
|
5
|
+
CheckOutcome,
|
|
6
|
+
ConnectivityReport,
|
|
7
|
+
IceProbeReport,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import { buildApiUrl, resolveServerBaseUrl, resolveServerUrls } from './serverUrls.js';
|
|
10
|
+
import type { ResolvedSerenadaConfig } from './configValidation.js';
|
|
11
|
+
import { resolveSerenadaConfig } from './configValidation.js';
|
|
12
|
+
import { formatError } from './formatError.js';
|
|
13
|
+
import { normalizeIceServers } from './iceServers.js';
|
|
14
|
+
|
|
15
|
+
interface DiagnosticTokenResponse {
|
|
16
|
+
token?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RoomIdResponse {
|
|
20
|
+
roomId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TurnCredentialsResponse {
|
|
24
|
+
username?: string;
|
|
25
|
+
password?: string;
|
|
26
|
+
uris?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pre-flight diagnostics utility. Checks device capabilities (camera, mic, speaker)
|
|
31
|
+
* and server connectivity (signaling, TURN) before joining a call.
|
|
32
|
+
*/
|
|
33
|
+
export class SerenadaDiagnostics {
|
|
34
|
+
private readonly resolvedConfig: ResolvedSerenadaConfig;
|
|
35
|
+
|
|
36
|
+
constructor(config: SerenadaConfig) {
|
|
37
|
+
this.resolvedConfig = resolveSerenadaConfig(config);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Run all diagnostic checks and return a full report. */
|
|
41
|
+
async runAll(): Promise<DiagnosticsReport> {
|
|
42
|
+
const devicesPromise = this.enumerateDevices();
|
|
43
|
+
const networkPromise = this.checkNetwork();
|
|
44
|
+
const turnPromise = this.checkTurn();
|
|
45
|
+
const signalingPromise = this.resolvedConfig.serverHost
|
|
46
|
+
? this.checkSignaling()
|
|
47
|
+
: Promise.resolve({ status: 'skipped', reason: 'requires serverHost' } as DiagnosticCheckResult & { transport?: string });
|
|
48
|
+
|
|
49
|
+
const [devices, network, signaling, turn] = await Promise.all([
|
|
50
|
+
devicesPromise,
|
|
51
|
+
networkPromise,
|
|
52
|
+
signalingPromise,
|
|
53
|
+
turnPromise,
|
|
54
|
+
]);
|
|
55
|
+
const camera = this.checkMediaCapability(devices, 'videoinput', 'No camera found');
|
|
56
|
+
const microphone = this.checkMediaCapability(devices, 'audioinput', 'No microphone found');
|
|
57
|
+
const speaker = this.checkDeviceAvailability(devices, 'audiooutput', 'No speaker found');
|
|
58
|
+
return { camera, microphone, speaker, network, signaling, turn, devices };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Test server connectivity: room API, WebSocket, SSE, and TURN credentials. */
|
|
62
|
+
async runConnectivityChecks(): Promise<ConnectivityReport> {
|
|
63
|
+
const serverHost = this.resolvedConfig.serverHost;
|
|
64
|
+
if (!serverHost) throw new Error('requires serverHost');
|
|
65
|
+
// Fetch the diagnostic token once and reuse it for the TURN credentials check.
|
|
66
|
+
let tokenForTurn: string | undefined;
|
|
67
|
+
const [roomApi, webSocket, sse, diagnosticToken] = await Promise.all([
|
|
68
|
+
this.runTimedCheck(async () => {
|
|
69
|
+
await this.createRoomId(serverHost);
|
|
70
|
+
}),
|
|
71
|
+
this.runTimedCheck(async () => {
|
|
72
|
+
await this.testWebSocket(serverHost);
|
|
73
|
+
}),
|
|
74
|
+
this.runTimedCheck(async () => {
|
|
75
|
+
await this.testSse(serverHost);
|
|
76
|
+
}),
|
|
77
|
+
this.runTimedCheck(async () => {
|
|
78
|
+
tokenForTurn = await this.fetchDiagnosticToken(serverHost);
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const turnCredentials = await this.runTimedCheck(async () => {
|
|
83
|
+
const token = tokenForTurn ?? await this.fetchDiagnosticToken(serverHost);
|
|
84
|
+
await this.fetchTurnCredentials(serverHost, token);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { roomApi, webSocket, sse, diagnosticToken, turnCredentials };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Probe ICE connectivity using the active server or provider ICE source. */
|
|
91
|
+
async runTurnProbe(turnsOnly: boolean, onCandidateLog?: (candidate: string) => void): Promise<IceProbeReport> {
|
|
92
|
+
try {
|
|
93
|
+
const iceServers = await this.resolveIceServers();
|
|
94
|
+
return await this.gatherIceCandidates(iceServers, turnsOnly, onCandidateLog);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { stunPassed: false, turnPassed: false, logs: [formatError(err)] };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Probe ICE connectivity (STUN/TURN) by gathering candidates with a real peer connection. */
|
|
101
|
+
async runIceProbe(turnsOnly: boolean, onCandidateLog?: (candidate: string) => void): Promise<IceProbeReport> {
|
|
102
|
+
return this.runTurnProbe(turnsOnly, onCandidateLog);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Validate that a server host is reachable by requesting a room ID. */
|
|
106
|
+
async validateServerHost(host?: string): Promise<void> {
|
|
107
|
+
if (!host) {
|
|
108
|
+
const resolved = this.resolvedConfig.serverHost;
|
|
109
|
+
if (!resolved) throw new Error('requires serverHost');
|
|
110
|
+
host = resolved;
|
|
111
|
+
}
|
|
112
|
+
const response = await this.fetchJson<RoomIdResponse>(buildApiUrl(host, '/api/room-id'), {
|
|
113
|
+
method: 'GET',
|
|
114
|
+
timeoutMs: 5000,
|
|
115
|
+
});
|
|
116
|
+
if (typeof response.roomId !== 'string' || response.roomId.trim().length === 0) {
|
|
117
|
+
throw new Error('Room ID missing');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Check if a camera is available and authorized. */
|
|
122
|
+
async checkCamera(): Promise<DiagnosticCheckResult> {
|
|
123
|
+
const devices = await this.enumerateDevices();
|
|
124
|
+
return this.checkMediaCapability(devices, 'videoinput', 'No camera found');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Check if a microphone is available and authorized. */
|
|
128
|
+
async checkMicrophone(): Promise<DiagnosticCheckResult> {
|
|
129
|
+
const devices = await this.enumerateDevices();
|
|
130
|
+
return this.checkMediaCapability(devices, 'audioinput', 'No microphone found');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Check if a speaker/audio output device is available. */
|
|
134
|
+
async checkSpeaker(): Promise<DiagnosticCheckResult> {
|
|
135
|
+
const devices = await this.enumerateDevices();
|
|
136
|
+
return this.checkDeviceAvailability(devices, 'audiooutput', 'No speaker found');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Check if the browser reports network connectivity. */
|
|
140
|
+
async checkNetwork(): Promise<DiagnosticCheckResult> {
|
|
141
|
+
try {
|
|
142
|
+
if (!navigator.onLine) return { status: 'unavailable', reason: 'Browser reports offline' };
|
|
143
|
+
return { status: 'available' };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { status: 'skipped', reason: String(err) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Check if the signaling server is reachable. */
|
|
150
|
+
async checkSignaling(): Promise<DiagnosticCheckResult & { transport?: string }> {
|
|
151
|
+
const serverHost = this.resolvedConfig.serverHost;
|
|
152
|
+
if (!serverHost) {
|
|
153
|
+
return { status: 'skipped', reason: 'requires serverHost' };
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
158
|
+
const res = await fetch(buildApiUrl(serverHost, '/api/room-id'), {
|
|
159
|
+
method: 'GET',
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
});
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
if (res.ok || res.status === 405) {
|
|
164
|
+
return { status: 'available', transport: 'ws' };
|
|
165
|
+
}
|
|
166
|
+
return { status: 'unavailable', reason: `Server returned ${res.status}` };
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return { status: 'unavailable', reason: String(err) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Check if the TURN relay endpoint is reachable. */
|
|
173
|
+
async checkTurn(): Promise<DiagnosticCheckResult & { latencyMs?: number }> {
|
|
174
|
+
const serverHost = this.resolvedConfig.serverHost;
|
|
175
|
+
if (!serverHost) {
|
|
176
|
+
try {
|
|
177
|
+
await this.resolveIceServers();
|
|
178
|
+
return { status: 'available' };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { status: 'unavailable', reason: String(err) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
const res = await this.fetchResponse(buildApiUrl(serverHost, '/api/turn-credentials?token=probe'), {
|
|
186
|
+
timeoutMs: 5000,
|
|
187
|
+
});
|
|
188
|
+
const latencyMs = Date.now() - start;
|
|
189
|
+
if (res.ok) {
|
|
190
|
+
return { status: 'available', latencyMs };
|
|
191
|
+
}
|
|
192
|
+
// 401/403 is expected without a valid token but means the endpoint is reachable
|
|
193
|
+
if (res.status === 401 || res.status === 403) {
|
|
194
|
+
return { status: 'available', latencyMs };
|
|
195
|
+
}
|
|
196
|
+
return { status: 'unavailable', reason: `TURN endpoint returned ${res.status}` };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return { status: 'unavailable', reason: String(err) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private checkMediaCapability(
|
|
203
|
+
devices: MediaDeviceInfo[],
|
|
204
|
+
deviceKind: MediaDeviceKind,
|
|
205
|
+
notFoundMsg: string,
|
|
206
|
+
): DiagnosticCheckResult {
|
|
207
|
+
const matching = devices.filter(d => d.kind === deviceKind);
|
|
208
|
+
// If labels are empty, permissions haven't been granted yet
|
|
209
|
+
if (matching.length > 0 && matching.every(d => !d.label)) {
|
|
210
|
+
return { status: 'notAuthorized' };
|
|
211
|
+
}
|
|
212
|
+
if (matching.length === 0) return { status: 'unavailable', reason: notFoundMsg };
|
|
213
|
+
return { status: 'available' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private checkDeviceAvailability(
|
|
217
|
+
devices: MediaDeviceInfo[],
|
|
218
|
+
deviceKind: MediaDeviceKind,
|
|
219
|
+
notFoundMsg: string,
|
|
220
|
+
): DiagnosticCheckResult {
|
|
221
|
+
const matching = devices.filter(d => d.kind === deviceKind);
|
|
222
|
+
if (matching.length === 0) return { status: 'unavailable', reason: notFoundMsg };
|
|
223
|
+
return { status: 'available' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async enumerateDevices(): Promise<MediaDeviceInfo[]> {
|
|
227
|
+
try {
|
|
228
|
+
if (!navigator.mediaDevices?.enumerateDevices) return [];
|
|
229
|
+
return await navigator.mediaDevices.enumerateDevices();
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private async runTimedCheck(block: () => Promise<void>): Promise<CheckOutcome> {
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
try {
|
|
238
|
+
await block();
|
|
239
|
+
return { status: 'passed', latencyMs: Date.now() - start };
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return { status: 'failed', error: formatError(err) };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async createRoomId(serverHost: string): Promise<string> {
|
|
246
|
+
const response = await this.fetchJson<RoomIdResponse>(buildApiUrl(serverHost, '/api/room-id'), {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
249
|
+
body: '',
|
|
250
|
+
timeoutMs: 5000,
|
|
251
|
+
});
|
|
252
|
+
if (typeof response.roomId !== 'string' || response.roomId.trim().length === 0) {
|
|
253
|
+
throw new Error('Room ID missing');
|
|
254
|
+
}
|
|
255
|
+
return response.roomId;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async fetchDiagnosticToken(serverHost: string): Promise<string> {
|
|
259
|
+
const response = await this.fetchJson<DiagnosticTokenResponse>(buildApiUrl(serverHost, '/api/diagnostic-token'), {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'Content-Type': 'application/json' },
|
|
262
|
+
body: '',
|
|
263
|
+
timeoutMs: 5000,
|
|
264
|
+
});
|
|
265
|
+
const token = response.token?.trim();
|
|
266
|
+
if (!token) {
|
|
267
|
+
throw new Error('Diagnostic token missing');
|
|
268
|
+
}
|
|
269
|
+
return token;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async fetchTurnCredentials(serverHost: string, token: string): Promise<Required<TurnCredentialsResponse>> {
|
|
273
|
+
const response = await this.fetchJson<TurnCredentialsResponse>(
|
|
274
|
+
buildApiUrl(serverHost, `/api/turn-credentials?token=${encodeURIComponent(token)}`),
|
|
275
|
+
{ timeoutMs: 5000 },
|
|
276
|
+
);
|
|
277
|
+
if (
|
|
278
|
+
typeof response.username !== 'string' ||
|
|
279
|
+
response.username.trim().length === 0 ||
|
|
280
|
+
typeof response.password !== 'string' ||
|
|
281
|
+
response.password.trim().length === 0 ||
|
|
282
|
+
!Array.isArray(response.uris) ||
|
|
283
|
+
response.uris.length === 0
|
|
284
|
+
) {
|
|
285
|
+
throw new Error('Invalid TURN credentials');
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
username: response.username,
|
|
289
|
+
password: response.password,
|
|
290
|
+
uris: response.uris,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async testWebSocket(serverHost: string): Promise<void> {
|
|
295
|
+
if (typeof WebSocket === 'undefined') {
|
|
296
|
+
throw new Error('WebSocket not available');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const { wsUrl } = resolveServerUrls(serverHost);
|
|
300
|
+
await new Promise<void>((resolve, reject) => {
|
|
301
|
+
let settled = false;
|
|
302
|
+
const socket = new WebSocket(wsUrl);
|
|
303
|
+
const timeout = globalThis.setTimeout(() => {
|
|
304
|
+
finish(() => reject(new Error('WebSocket timeout')));
|
|
305
|
+
}, 5000);
|
|
306
|
+
|
|
307
|
+
const finish = (callback: () => void) => {
|
|
308
|
+
if (settled) return;
|
|
309
|
+
settled = true;
|
|
310
|
+
globalThis.clearTimeout(timeout);
|
|
311
|
+
socket.onopen = null;
|
|
312
|
+
socket.onerror = null;
|
|
313
|
+
callback();
|
|
314
|
+
socket.close(1000, 'diagnostics');
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
socket.onopen = () => {
|
|
318
|
+
finish(resolve);
|
|
319
|
+
};
|
|
320
|
+
socket.onerror = () => {
|
|
321
|
+
finish(() => reject(new Error('WebSocket failed')));
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async testSse(serverHost: string): Promise<void> {
|
|
327
|
+
if (typeof EventSource === 'undefined') {
|
|
328
|
+
throw new Error('EventSource not available');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const baseUrl = resolveServerBaseUrl(serverHost);
|
|
332
|
+
const sid = `diag-${Math.random().toString(36).slice(2, 10)}`;
|
|
333
|
+
const sseUrl = `${baseUrl}/sse?sid=${encodeURIComponent(sid)}`;
|
|
334
|
+
|
|
335
|
+
await new Promise<void>((resolve, reject) => {
|
|
336
|
+
let settled = false;
|
|
337
|
+
const eventSource = new EventSource(sseUrl);
|
|
338
|
+
const timeout = globalThis.setTimeout(() => {
|
|
339
|
+
finish(() => reject(new Error('SSE timeout')));
|
|
340
|
+
}, 5000);
|
|
341
|
+
|
|
342
|
+
const finish = (callback: () => void) => {
|
|
343
|
+
if (settled) return;
|
|
344
|
+
settled = true;
|
|
345
|
+
globalThis.clearTimeout(timeout);
|
|
346
|
+
eventSource.close();
|
|
347
|
+
callback();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
eventSource.onopen = async () => {
|
|
351
|
+
try {
|
|
352
|
+
const response = await this.fetchResponse(sseUrl, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ v: 1, type: 'ping', payload: { ts: Date.now() } }),
|
|
356
|
+
timeoutMs: 5000,
|
|
357
|
+
});
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new Error(`SSE ping failed: ${response.status}`);
|
|
360
|
+
}
|
|
361
|
+
finish(resolve);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
finish(() => reject(err));
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
eventSource.onerror = () => {
|
|
367
|
+
finish(() => reject(new Error('SSE connection failed')));
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async gatherIceCandidates(
|
|
373
|
+
iceServers: RTCIceServer[],
|
|
374
|
+
turnsOnly: boolean,
|
|
375
|
+
onCandidateLog?: (candidate: string) => void,
|
|
376
|
+
): Promise<IceProbeReport> {
|
|
377
|
+
if (typeof RTCPeerConnection === 'undefined') {
|
|
378
|
+
return { stunPassed: false, turnPassed: false, logs: ['WebRTC not available'] };
|
|
379
|
+
}
|
|
380
|
+
const normalizedIceServers = normalizeIceServers(iceServers, turnsOnly);
|
|
381
|
+
if (normalizedIceServers.length === 0) {
|
|
382
|
+
return { stunPassed: false, turnPassed: false, logs: ['No ICE servers'] };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const logs: string[] = [];
|
|
386
|
+
const log = (message: string) => {
|
|
387
|
+
logs.push(message);
|
|
388
|
+
onCandidateLog?.(message);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
return await new Promise<IceProbeReport>((resolve) => {
|
|
392
|
+
let settled = false;
|
|
393
|
+
let stunPassed = false;
|
|
394
|
+
let turnPassed = false;
|
|
395
|
+
const iceServersSummary = normalizedIceServers
|
|
396
|
+
.flatMap((iceServer) => Array.isArray(iceServer.urls) ? iceServer.urls : [iceServer.urls])
|
|
397
|
+
.join(', ');
|
|
398
|
+
const connection = new RTCPeerConnection({
|
|
399
|
+
iceServers: normalizedIceServers,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const finish = () => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
globalThis.clearTimeout(timeout);
|
|
406
|
+
connection.onicecandidate = null;
|
|
407
|
+
connection.onicecandidateerror = null;
|
|
408
|
+
connection.onicegatheringstatechange = null;
|
|
409
|
+
connection.close();
|
|
410
|
+
resolve({ stunPassed, turnPassed, logs, iceServersSummary });
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const timeout = globalThis.setTimeout(() => {
|
|
414
|
+
log('ICE gathering timed out');
|
|
415
|
+
finish();
|
|
416
|
+
}, 10000);
|
|
417
|
+
|
|
418
|
+
connection.onicecandidate = (event) => {
|
|
419
|
+
const candidate = event.candidate?.candidate;
|
|
420
|
+
if (!candidate) {
|
|
421
|
+
finish();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
log(candidate);
|
|
426
|
+
if (candidate.includes(' typ srflx ')) {
|
|
427
|
+
stunPassed = true;
|
|
428
|
+
}
|
|
429
|
+
if (candidate.includes(' typ relay ')) {
|
|
430
|
+
turnPassed = true;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
connection.onicecandidateerror = (event) => {
|
|
434
|
+
log(`ICE candidate error: ${event.errorText || event.errorCode}`);
|
|
435
|
+
};
|
|
436
|
+
connection.onicegatheringstatechange = () => {
|
|
437
|
+
if (connection.iceGatheringState === 'complete') {
|
|
438
|
+
finish();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
connection.createDataChannel('diagnostics');
|
|
443
|
+
void connection.createOffer()
|
|
444
|
+
.then((offer) => connection.setLocalDescription(offer))
|
|
445
|
+
.catch((err) => {
|
|
446
|
+
log(`ICE probe failed: ${formatError(err)}`);
|
|
447
|
+
finish();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async resolveIceServers(): Promise<RTCIceServer[]> {
|
|
453
|
+
if (this.resolvedConfig.serverHost) {
|
|
454
|
+
const token = await this.fetchDiagnosticToken(this.resolvedConfig.serverHost);
|
|
455
|
+
const credentials = await this.fetchTurnCredentials(this.resolvedConfig.serverHost, token);
|
|
456
|
+
return [{
|
|
457
|
+
urls: credentials.uris,
|
|
458
|
+
username: credentials.username,
|
|
459
|
+
credential: credentials.password,
|
|
460
|
+
}];
|
|
461
|
+
}
|
|
462
|
+
return await (this.resolvedConfig.signalingProvider as NonNullable<ResolvedSerenadaConfig['signalingProvider']>).getIceServers();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async fetchJson<T>(url: string, options: RequestInit & { timeoutMs: number }): Promise<T> {
|
|
466
|
+
const response = await this.fetchResponse(url, options);
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
throw new Error(`Request failed: ${response.status}`);
|
|
469
|
+
}
|
|
470
|
+
return await response.json() as T;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private async fetchResponse(url: string, options: RequestInit & { timeoutMs: number }): Promise<Response> {
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), options.timeoutMs);
|
|
476
|
+
try {
|
|
477
|
+
return await fetch(url, {
|
|
478
|
+
...options,
|
|
479
|
+
signal: controller.signal,
|
|
480
|
+
});
|
|
481
|
+
} finally {
|
|
482
|
+
globalThis.clearTimeout(timeout);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|