livekit-server-sdk 2.16.0 → 2.17.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/dist/AgentDispatchClient.cjs +5 -3
- package/dist/AgentDispatchClient.cjs.map +1 -1
- package/dist/AgentDispatchClient.js +5 -3
- package/dist/AgentDispatchClient.js.map +1 -1
- package/dist/ClientOptions.cjs.map +1 -1
- package/dist/ClientOptions.d.cts +12 -0
- package/dist/ClientOptions.d.ts +12 -0
- package/dist/ClientOptions.d.ts.map +1 -1
- package/dist/ConnectorClient.cjs +9 -5
- package/dist/ConnectorClient.cjs.map +1 -1
- package/dist/ConnectorClient.d.cts +6 -3
- package/dist/ConnectorClient.d.ts +6 -3
- package/dist/ConnectorClient.d.ts.map +1 -1
- package/dist/ConnectorClient.js +9 -5
- package/dist/ConnectorClient.js.map +1 -1
- package/dist/EgressClient.cjs +5 -3
- package/dist/EgressClient.cjs.map +1 -1
- package/dist/EgressClient.js +5 -3
- package/dist/EgressClient.js.map +1 -1
- package/dist/IngressClient.cjs +5 -3
- package/dist/IngressClient.cjs.map +1 -1
- package/dist/IngressClient.js +5 -3
- package/dist/IngressClient.js.map +1 -1
- package/dist/LiveKitAPI.cjs +80 -0
- package/dist/LiveKitAPI.cjs.map +1 -0
- package/dist/LiveKitAPI.d.cts +77 -0
- package/dist/LiveKitAPI.d.ts +77 -0
- package/dist/LiveKitAPI.d.ts.map +1 -0
- package/dist/LiveKitAPI.js +56 -0
- package/dist/LiveKitAPI.js.map +1 -0
- package/dist/RoomServiceClient.cjs +5 -3
- package/dist/RoomServiceClient.cjs.map +1 -1
- package/dist/RoomServiceClient.js +5 -3
- package/dist/RoomServiceClient.js.map +1 -1
- package/dist/ServiceBase.cjs +9 -9
- package/dist/ServiceBase.cjs.map +1 -1
- package/dist/ServiceBase.d.cts +20 -1
- package/dist/ServiceBase.d.ts +20 -1
- package/dist/ServiceBase.d.ts.map +1 -1
- package/dist/ServiceBase.js +9 -9
- package/dist/ServiceBase.js.map +1 -1
- package/dist/SipClient.cjs +40 -19
- package/dist/SipClient.cjs.map +1 -1
- package/dist/SipClient.d.cts +4 -2
- package/dist/SipClient.d.ts +4 -2
- package/dist/SipClient.d.ts.map +1 -1
- package/dist/SipClient.js +41 -20
- package/dist/SipClient.js.map +1 -1
- package/dist/TwirpRPC.cjs +135 -48
- package/dist/TwirpRPC.cjs.map +1 -1
- package/dist/TwirpRPC.d.cts +38 -2
- package/dist/TwirpRPC.d.ts +38 -2
- package/dist/TwirpRPC.d.ts.map +1 -1
- package/dist/TwirpRPC.js +140 -38
- package/dist/TwirpRPC.js.map +1 -1
- package/dist/dialTimeout.cjs +39 -0
- package/dist/dialTimeout.cjs.map +1 -0
- package/dist/dialTimeout.d.cts +21 -0
- package/dist/dialTimeout.d.ts +21 -0
- package/dist/dialTimeout.d.ts.map +1 -0
- package/dist/dialTimeout.js +13 -0
- package/dist/dialTimeout.js.map +1 -0
- package/dist/failover.cjs +139 -0
- package/dist/failover.cjs.map +1 -0
- package/dist/failover.d.cts +30 -0
- package/dist/failover.d.ts +30 -0
- package/dist/failover.d.ts.map +1 -0
- package/dist/failover.js +107 -0
- package/dist/failover.js.map +1 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/version.cjs +29 -0
- package/dist/version.cjs.map +1 -0
- package/dist/version.d.cts +3 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +2 -2
- package/src/AgentDispatchClient.ts +5 -5
- package/src/ClientOptions.ts +12 -0
- package/src/ConnectorClient.ts +20 -11
- package/src/EgressClient.ts +5 -5
- package/src/IngressClient.ts +5 -5
- package/src/LiveKitAPI.ts +126 -0
- package/src/RoomServiceClient.ts +5 -5
- package/src/ServiceBase.ts +35 -4
- package/src/SipClient.ts +55 -23
- package/src/TwirpRPC.test.ts +42 -0
- package/src/TwirpRPC.ts +187 -39
- package/src/dialTimeout.test.ts +41 -0
- package/src/dialTimeout.ts +37 -0
- package/src/failover.test.ts +188 -0
- package/src/failover.ts +170 -0
- package/src/index.ts +3 -1
- package/src/version.ts +1 -0
package/src/TwirpRPC.ts
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
import type { JsonValue } from '@bufbuild/protobuf';
|
|
5
|
+
import {
|
|
6
|
+
FAILOVER_BACKOFF_BASE_MS,
|
|
7
|
+
failoverAttempts,
|
|
8
|
+
hostKey,
|
|
9
|
+
pickNext,
|
|
10
|
+
regionOrigins,
|
|
11
|
+
sleep,
|
|
12
|
+
} from './failover.js';
|
|
13
|
+
import { SDK_VERSION } from './version.js';
|
|
14
|
+
|
|
15
|
+
// Identifies the SDK and version to the server on every request. Browsers forbid
|
|
16
|
+
// setting User-Agent via fetch and silently drop it; Node honors it.
|
|
17
|
+
const USER_AGENT = `livekit-server-sdk-node/${SDK_VERSION}`;
|
|
5
18
|
|
|
6
19
|
// twirp RPC adapter for client implementation
|
|
7
20
|
|
|
@@ -10,10 +23,16 @@ type Options = {
|
|
|
10
23
|
prefix?: string;
|
|
11
24
|
/** Timeout for fetch requests, in seconds. Must be within the valid range for abort signal timeouts. */
|
|
12
25
|
requestTimeout?: number;
|
|
26
|
+
/** Whether region failover is enabled (LiveKit Cloud hosts only). Defaults to true. */
|
|
27
|
+
failover?: boolean;
|
|
28
|
+
/** @internal test-only: force failover regardless of host. */
|
|
29
|
+
failoverForce?: boolean;
|
|
30
|
+
/** @internal test-only: base retry backoff in ms. */
|
|
31
|
+
failoverBackoffMs?: number;
|
|
13
32
|
};
|
|
14
33
|
|
|
15
34
|
const defaultPrefix = '/twirp';
|
|
16
|
-
const defaultTimeoutSeconds =
|
|
35
|
+
const defaultTimeoutSeconds = 10;
|
|
17
36
|
|
|
18
37
|
export const livekitPackage = 'livekit';
|
|
19
38
|
export interface Rpc {
|
|
@@ -26,7 +45,7 @@ export interface Rpc {
|
|
|
26
45
|
): Promise<string>;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
export class
|
|
48
|
+
export class ServerError extends Error {
|
|
30
49
|
status: number;
|
|
31
50
|
code?: string;
|
|
32
51
|
metadata?: Record<string, string>;
|
|
@@ -46,6 +65,68 @@ export class TwirpError extends Error {
|
|
|
46
65
|
}
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
/** @deprecated use {@link ServerError} */
|
|
69
|
+
export const TwirpError = ServerError;
|
|
70
|
+
/** @deprecated use {@link ServerError} */
|
|
71
|
+
export type TwirpError = ServerError;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A {@link ServerError} from a SIP dialing call (`createSipParticipant` /
|
|
75
|
+
* `transferSipParticipant`) that failed with a SIP response status. The SIP code
|
|
76
|
+
* and reason are exposed as getters; any other error metadata remains available
|
|
77
|
+
* via {@link ServerError.metadata}.
|
|
78
|
+
*/
|
|
79
|
+
export class SipCallError extends ServerError {
|
|
80
|
+
constructor(
|
|
81
|
+
name: string,
|
|
82
|
+
message: string,
|
|
83
|
+
status: number,
|
|
84
|
+
code?: string,
|
|
85
|
+
metadata?: Record<string, string>,
|
|
86
|
+
) {
|
|
87
|
+
super(name, SipCallError.describe(message, code, metadata), status, code, metadata);
|
|
88
|
+
this.name = 'SipCallError';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The SIP response code of the failed call, e.g. 486 (Busy Here). */
|
|
92
|
+
get sipStatusCode(): number | undefined {
|
|
93
|
+
const raw = this.metadata?.sip_status_code;
|
|
94
|
+
return raw !== undefined ? Number(raw) : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The SIP reason phrase of the failed call, e.g. "Busy Here". */
|
|
98
|
+
get sipStatus(): string | undefined {
|
|
99
|
+
return this.metadata?.sip_status;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Builds a SipCallError from a ServerError, preserving its code and metadata. */
|
|
103
|
+
static fromServerError(err: ServerError): SipCallError {
|
|
104
|
+
return new SipCallError(err.name, err.message, err.status, err.code, err.metadata);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// describe renders a clear message: the SIP status, the error code, and any
|
|
108
|
+
// other metadata the server attached. Falls back to the raw message when the
|
|
109
|
+
// error carries no SIP status.
|
|
110
|
+
private static describe(fallback: string, code?: string, metadata?: Record<string, string>) {
|
|
111
|
+
const sipCode = metadata?.sip_status_code;
|
|
112
|
+
if (!sipCode) {
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
const reason = metadata?.sip_status;
|
|
116
|
+
let msg = `SIP call failed: ${sipCode}${reason ? ` ${reason}` : ''}`;
|
|
117
|
+
if (code) {
|
|
118
|
+
msg += ` (${code})`;
|
|
119
|
+
}
|
|
120
|
+
const extra = Object.entries(metadata ?? {})
|
|
121
|
+
.filter(([k]) => k !== 'sip_status_code' && k !== 'sip_status' && k !== 'error_details')
|
|
122
|
+
.map(([k, v]) => `${k}=${v}`);
|
|
123
|
+
if (extra.length) {
|
|
124
|
+
msg += ` [${extra.join(', ')}]`;
|
|
125
|
+
}
|
|
126
|
+
return msg;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
49
130
|
/**
|
|
50
131
|
* JSON based Twirp V7 RPC
|
|
51
132
|
*/
|
|
@@ -58,6 +139,12 @@ export class TwirpRpc {
|
|
|
58
139
|
|
|
59
140
|
requestTimeout: number;
|
|
60
141
|
|
|
142
|
+
failover: boolean;
|
|
143
|
+
|
|
144
|
+
private failoverForce: boolean;
|
|
145
|
+
|
|
146
|
+
private failoverBackoffMs: number;
|
|
147
|
+
|
|
61
148
|
constructor(host: string, pkg: string, options?: Options) {
|
|
62
149
|
if (host.startsWith('ws')) {
|
|
63
150
|
host = host.replace('ws', 'http');
|
|
@@ -66,8 +153,18 @@ export class TwirpRpc {
|
|
|
66
153
|
this.pkg = pkg;
|
|
67
154
|
this.requestTimeout = options?.requestTimeout ?? defaultTimeoutSeconds;
|
|
68
155
|
this.prefix = options?.prefix || defaultPrefix;
|
|
156
|
+
this.failover = options?.failover ?? true;
|
|
157
|
+
this.failoverForce = options?.failoverForce ?? false;
|
|
158
|
+
this.failoverBackoffMs = options?.failoverBackoffMs ?? FAILOVER_BACKOFF_BASE_MS;
|
|
69
159
|
}
|
|
70
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Issues a Twirp request, failing over to alternative regions on retryable
|
|
163
|
+
* errors. On any transport error or HTTP 5xx it discovers regions via
|
|
164
|
+
* /settings/regions and replays the request — body and headers intact —
|
|
165
|
+
* against the next untried region, with exponential backoff. A 4xx is
|
|
166
|
+
* returned immediately.
|
|
167
|
+
*/
|
|
71
168
|
async request(
|
|
72
169
|
service: string,
|
|
73
170
|
method: string,
|
|
@@ -77,52 +174,103 @@ export class TwirpRpc {
|
|
|
77
174
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
175
|
): Promise<any> {
|
|
79
176
|
const path = `${this.prefix}/${this.pkg}.${service}/${method}`;
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
...headers,
|
|
86
|
-
},
|
|
87
|
-
body: JSON.stringify(data),
|
|
177
|
+
const body = JSON.stringify(data);
|
|
178
|
+
const requestHeaders = {
|
|
179
|
+
'Content-Type': 'application/json;charset=UTF-8',
|
|
180
|
+
'User-Agent': USER_AGENT,
|
|
181
|
+
...headers,
|
|
88
182
|
};
|
|
89
183
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
184
|
+
const origin = new URL(this.host);
|
|
185
|
+
const maxAttempts = failoverAttempts(
|
|
186
|
+
this.failover,
|
|
187
|
+
origin.hostname,
|
|
188
|
+
this.failoverForce,
|
|
189
|
+
timeout,
|
|
190
|
+
);
|
|
191
|
+
const attempted = new Set([hostKey(origin)]);
|
|
192
|
+
let regions: string[] | undefined;
|
|
193
|
+
let current = this.host;
|
|
93
194
|
|
|
94
|
-
|
|
195
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
196
|
+
const isLast = attempt + 1 >= maxAttempts;
|
|
197
|
+
const init: RequestInit = { method: 'POST', headers: requestHeaders, body };
|
|
198
|
+
if (timeout) {
|
|
199
|
+
init.signal = AbortSignal.timeout(timeout * 1000);
|
|
200
|
+
}
|
|
95
201
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let errorMessage = 'Unknown internal error';
|
|
99
|
-
let errorCode: string | undefined = undefined;
|
|
100
|
-
let metadata: Record<string, string> | undefined = undefined;
|
|
202
|
+
let response: Response | undefined;
|
|
203
|
+
let transportError: unknown;
|
|
101
204
|
try {
|
|
102
|
-
|
|
103
|
-
const parsedError = (await response.json()) as Record<string, unknown>;
|
|
104
|
-
if ('msg' in parsedError) {
|
|
105
|
-
errorMessage = <string>parsedError.msg;
|
|
106
|
-
}
|
|
107
|
-
if ('code' in parsedError) {
|
|
108
|
-
errorCode = <string>parsedError.code;
|
|
109
|
-
}
|
|
110
|
-
if ('meta' in parsedError) {
|
|
111
|
-
metadata = <Record<string, string>>parsedError.meta;
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
errorMessage = await response.text();
|
|
115
|
-
}
|
|
205
|
+
response = await fetch(new URL(path, current), init);
|
|
116
206
|
} catch (e) {
|
|
117
|
-
|
|
118
|
-
|
|
207
|
+
transportError = e;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (response?.ok) {
|
|
211
|
+
// Return the raw JSON. Every caller parses it with protobuf-es
|
|
212
|
+
// fromJson(), which per the proto3 JSON spec accepts both the proto
|
|
213
|
+
// field names (snake_case) and their json_name (camelCase), so no key
|
|
214
|
+
// conversion is needed. Converting keys would also corrupt map<string,…>
|
|
215
|
+
// entries (e.g. participant attributes), whose keys are user data.
|
|
216
|
+
return (await response.json()) as Record<string, unknown>;
|
|
119
217
|
}
|
|
120
218
|
|
|
121
|
-
|
|
219
|
+
// Only retryable failures (a transport error or HTTP 5xx) continue;
|
|
220
|
+
// a 4xx is terminal.
|
|
221
|
+
const retryable = transportError !== undefined || (!!response && response.status >= 500);
|
|
222
|
+
let next: string | undefined;
|
|
223
|
+
if (retryable && !isLast) {
|
|
224
|
+
if (!regions) {
|
|
225
|
+
regions = await regionOrigins(origin, headers);
|
|
226
|
+
}
|
|
227
|
+
next = pickNext(regions, attempted);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!retryable || next === undefined) {
|
|
231
|
+
if (response) {
|
|
232
|
+
throw await toTwirpError(response);
|
|
233
|
+
}
|
|
234
|
+
throw transportError;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const reason = response ? `status ${response.status}` : transportError;
|
|
238
|
+
console.warn(
|
|
239
|
+
`livekit API request to ${new URL(current).host} failed (${reason}), retrying with fallback url ${next}`,
|
|
240
|
+
);
|
|
241
|
+
await sleep(this.failoverBackoffMs * 2 ** attempt);
|
|
242
|
+
attempted.add(hostKey(new URL(next)));
|
|
243
|
+
current = next;
|
|
122
244
|
}
|
|
123
|
-
const parsedResp = (await response.json()) as Record<string, unknown>;
|
|
124
245
|
|
|
125
|
-
|
|
126
|
-
|
|
246
|
+
throw new Error('failover loop exited without returning'); // unreachable
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Builds a TwirpError from a non-2xx response, mirroring Twirp's JSON error shape. */
|
|
251
|
+
async function toTwirpError(response: Response): Promise<TwirpError> {
|
|
252
|
+
const isJson = response.headers.get('content-type') === 'application/json';
|
|
253
|
+
let errorMessage = 'Unknown internal error';
|
|
254
|
+
let errorCode: string | undefined = undefined;
|
|
255
|
+
let metadata: Record<string, string> | undefined = undefined;
|
|
256
|
+
try {
|
|
257
|
+
if (isJson) {
|
|
258
|
+
const parsedError = (await response.json()) as Record<string, unknown>;
|
|
259
|
+
if ('msg' in parsedError) {
|
|
260
|
+
errorMessage = <string>parsedError.msg;
|
|
261
|
+
}
|
|
262
|
+
if ('code' in parsedError) {
|
|
263
|
+
errorCode = <string>parsedError.code;
|
|
264
|
+
}
|
|
265
|
+
if ('meta' in parsedError) {
|
|
266
|
+
metadata = <Record<string, string>>parsedError.meta;
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
errorMessage = await response.text();
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
// parsing went wrong, no op and we keep default error message
|
|
273
|
+
console.debug(`Error when trying to parse error message, using defaults`, e);
|
|
127
274
|
}
|
|
275
|
+
return new TwirpError(response.statusText, errorMessage, response.status, errorCode, metadata);
|
|
128
276
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_RINGING_TIMEOUT_SECONDS,
|
|
7
|
+
RINGING_TIMEOUT_MARGIN_SECONDS,
|
|
8
|
+
dialRequestTimeout,
|
|
9
|
+
} from './dialTimeout.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_FLOOR = DEFAULT_RINGING_TIMEOUT_SECONDS + RINGING_TIMEOUT_MARGIN_SECONDS;
|
|
12
|
+
|
|
13
|
+
describe('dialRequestTimeout', () => {
|
|
14
|
+
it('falls back to the default ring window plus margin when nothing is set', () => {
|
|
15
|
+
// No ringing timeout means the server's default ring applies, so the request
|
|
16
|
+
// must still outlast it by the margin.
|
|
17
|
+
expect(dialRequestTimeout(undefined, undefined)).toBe(DEFAULT_FLOOR);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('honors a user timeout above the default floor, raises one below it', () => {
|
|
21
|
+
expect(dialRequestTimeout(45, undefined)).toBe(45);
|
|
22
|
+
expect(dialRequestTimeout(5, undefined)).toBe(DEFAULT_FLOOR);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tracks a short ringing timeout (ring + margin), no fixed floor', () => {
|
|
26
|
+
expect(dialRequestTimeout(undefined, 10)).toBe(10 + RINGING_TIMEOUT_MARGIN_SECONDS);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('raises the timeout to stay above a long ringing timeout', () => {
|
|
30
|
+
expect(dialRequestTimeout(undefined, 60)).toBe(60 + RINGING_TIMEOUT_MARGIN_SECONDS);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('raises a user timeout that is too tight for the ringing window', () => {
|
|
34
|
+
// user asked for 30s but ringing is 40s; must outlast ringing by the margin.
|
|
35
|
+
expect(dialRequestTimeout(30, 40)).toBe(40 + RINGING_TIMEOUT_MARGIN_SECONDS);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps a user timeout that already clears the ringing window', () => {
|
|
39
|
+
expect(dialRequestTimeout(90, 40)).toBe(90);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
// Shared request-timeout handling for calls that may block until a call is
|
|
6
|
+
// answered (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp
|
|
7
|
+
// AcceptWhatsAppCall). These take longer than a normal API call, and the request
|
|
8
|
+
// must outlast the wait or it would abort before the call is answered.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ring window (seconds) assumed when a request doesn't set a ringing timeout;
|
|
12
|
+
* matches the server default. A dialing request must outlast it.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_RINGING_TIMEOUT_SECONDS = 30;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* When a call waits on ringing, the request must outlast the ringing window or
|
|
18
|
+
* it would abort before the call can be answered. We keep at least this margin
|
|
19
|
+
* (seconds) of request timeout above the ringing timeout.
|
|
20
|
+
*/
|
|
21
|
+
export const RINGING_TIMEOUT_MARGIN_SECONDS = 2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the request timeout (seconds) for a phone-dialing call: the ring
|
|
25
|
+
* window plus a margin, so the request doesn't abort before the call can be
|
|
26
|
+
* answered. The ring window is the request's `ringingTimeout` when set, else
|
|
27
|
+
* {@link DEFAULT_RINGING_TIMEOUT_SECONDS}. A longer user-supplied `timeout` is
|
|
28
|
+
* honored; a shorter one is raised to the floor.
|
|
29
|
+
*/
|
|
30
|
+
export function dialRequestTimeout(
|
|
31
|
+
timeout: number | undefined,
|
|
32
|
+
ringingTimeout: number | undefined,
|
|
33
|
+
): number {
|
|
34
|
+
const ring = ringingTimeout ?? DEFAULT_RINGING_TIMEOUT_SECONDS;
|
|
35
|
+
const floor = ring + RINGING_TIMEOUT_MARGIN_SECONDS;
|
|
36
|
+
return Math.max(timeout ?? floor, floor);
|
|
37
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
FAILOVER_MAX_ATTEMPTS,
|
|
7
|
+
MIN_FAILOVER_TIMEOUT_SECONDS,
|
|
8
|
+
failoverAttempts,
|
|
9
|
+
hostKey,
|
|
10
|
+
parseMaxAge,
|
|
11
|
+
pickNext,
|
|
12
|
+
regionOrigins,
|
|
13
|
+
sleep,
|
|
14
|
+
} from './failover.js';
|
|
15
|
+
|
|
16
|
+
describe('failoverAttempts', () => {
|
|
17
|
+
it('fails over for LiveKit Cloud hosts when enabled', () => {
|
|
18
|
+
expect(failoverAttempts(true, 'myproject.livekit.cloud')).toBe(FAILOVER_MAX_ATTEMPTS);
|
|
19
|
+
expect(failoverAttempts(true, 'myproject.region.livekit.cloud')).toBe(FAILOVER_MAX_ATTEMPTS);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not fail over for non-cloud hosts', () => {
|
|
23
|
+
expect(failoverAttempts(true, 'myproject.livekit.io')).toBe(1);
|
|
24
|
+
expect(failoverAttempts(true, 'example.com')).toBe(1);
|
|
25
|
+
expect(failoverAttempts(true, '127.0.0.1')).toBe(1);
|
|
26
|
+
expect(failoverAttempts(true, 'notlivekit.cloud')).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('force bypasses the cloud-host check; disabled never fails over', () => {
|
|
30
|
+
expect(failoverAttempts(true, '127.0.0.1', true)).toBe(FAILOVER_MAX_ATTEMPTS);
|
|
31
|
+
expect(failoverAttempts(false, 'myproject.livekit.cloud', true)).toBe(1);
|
|
32
|
+
expect(failoverAttempts(false, 'myproject.livekit.cloud')).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies the thundering-herd guard for sub-threshold timeouts', () => {
|
|
36
|
+
expect(
|
|
37
|
+
failoverAttempts(true, 'myproject.livekit.cloud', false, MIN_FAILOVER_TIMEOUT_SECONDS - 1),
|
|
38
|
+
).toBe(1);
|
|
39
|
+
expect(
|
|
40
|
+
failoverAttempts(true, 'myproject.livekit.cloud', false, MIN_FAILOVER_TIMEOUT_SECONDS),
|
|
41
|
+
).toBe(FAILOVER_MAX_ATTEMPTS);
|
|
42
|
+
// 0 means "no timeout set" and is not guarded.
|
|
43
|
+
expect(failoverAttempts(true, 'myproject.livekit.cloud', false, 0)).toBe(FAILOVER_MAX_ATTEMPTS);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('hostKey', () => {
|
|
48
|
+
it('lowercases the host and keeps a non-default port', () => {
|
|
49
|
+
expect(hostKey(new URL('https://Example.LiveKit.Cloud/twirp'))).toBe('example.livekit.cloud');
|
|
50
|
+
expect(hostKey(new URL('https://example.livekit.cloud:8080/twirp'))).toBe(
|
|
51
|
+
'example.livekit.cloud:8080',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('pickNext', () => {
|
|
57
|
+
const origins = ['https://a.livekit.cloud', 'https://b.livekit.cloud'];
|
|
58
|
+
|
|
59
|
+
it('returns the first origin not yet attempted', () => {
|
|
60
|
+
expect(pickNext(origins, new Set())).toBe('https://a.livekit.cloud');
|
|
61
|
+
expect(pickNext(origins, new Set(['a.livekit.cloud']))).toBe('https://b.livekit.cloud');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns undefined once every origin has been attempted', () => {
|
|
65
|
+
expect(pickNext(origins, new Set(['a.livekit.cloud', 'b.livekit.cloud']))).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips malformed origin URLs', () => {
|
|
69
|
+
expect(pickNext(['not a url', 'https://c.livekit.cloud'], new Set())).toBe(
|
|
70
|
+
'https://c.livekit.cloud',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('sleep', () => {
|
|
76
|
+
it('resolves immediately for non-positive durations', async () => {
|
|
77
|
+
await expect(sleep(0)).resolves.toBeUndefined();
|
|
78
|
+
await expect(sleep(-5)).resolves.toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('parseMaxAge', () => {
|
|
83
|
+
it('returns 0 when the header is absent or empty', () => {
|
|
84
|
+
expect(parseMaxAge(null)).toBe(0);
|
|
85
|
+
expect(parseMaxAge('')).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('parses max-age (in seconds) to milliseconds', () => {
|
|
89
|
+
expect(parseMaxAge('max-age=300')).toBe(300_000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('finds max-age alongside other directives', () => {
|
|
93
|
+
expect(parseMaxAge('public, max-age=3600')).toBe(3_600_000);
|
|
94
|
+
expect(parseMaxAge('private, max-age=600, must-revalidate')).toBe(600_000);
|
|
95
|
+
expect(parseMaxAge('no-cache, max-age=30')).toBe(30_000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is case-insensitive and tolerates surrounding whitespace', () => {
|
|
99
|
+
expect(parseMaxAge('Max-Age=300')).toBe(300_000);
|
|
100
|
+
expect(parseMaxAge(' max-age = 300 ')).toBe(0); // spaces around "=" are not valid per RFC
|
|
101
|
+
expect(parseMaxAge('public, max-age=120')).toBe(120_000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('treats max-age=0 and non-caching directives as "do not cache"', () => {
|
|
105
|
+
expect(parseMaxAge('max-age=0')).toBe(0);
|
|
106
|
+
expect(parseMaxAge('no-cache')).toBe(0);
|
|
107
|
+
expect(parseMaxAge('no-store')).toBe(0);
|
|
108
|
+
expect(parseMaxAge('public')).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('ignores s-maxage and only honors max-age', () => {
|
|
112
|
+
expect(parseMaxAge('s-maxage=3600, max-age=120')).toBe(120_000);
|
|
113
|
+
expect(parseMaxAge('s-maxage=3600')).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns 0 for negative or non-numeric max-age', () => {
|
|
117
|
+
expect(parseMaxAge('max-age=-5')).toBe(0);
|
|
118
|
+
expect(parseMaxAge('max-age=abc')).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('regionOrigins', () => {
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const mockResponse = (regions: Array<{ url: string }>, cacheControl: string | null): Response =>
|
|
128
|
+
({
|
|
129
|
+
ok: true,
|
|
130
|
+
status: 200,
|
|
131
|
+
headers: { get: (h: string) => (h.toLowerCase() === 'cache-control' ? cacheControl : null) },
|
|
132
|
+
json: async () => ({ regions }),
|
|
133
|
+
}) as unknown as Response;
|
|
134
|
+
|
|
135
|
+
it('coalesces concurrent discovery fetches for the same origin', async () => {
|
|
136
|
+
let resolveFetch: (r: Response) => void = () => {};
|
|
137
|
+
const fetchSpy = vi
|
|
138
|
+
.spyOn(globalThis, 'fetch')
|
|
139
|
+
.mockImplementation(() => new Promise<Response>((resolve) => (resolveFetch = resolve)));
|
|
140
|
+
|
|
141
|
+
const origin = new URL('https://primary.coalesce.livekit.cloud');
|
|
142
|
+
const p1 = regionOrigins(origin, {});
|
|
143
|
+
const p2 = regionOrigins(origin, {});
|
|
144
|
+
|
|
145
|
+
// Both callers share a single in-flight request.
|
|
146
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
147
|
+
|
|
148
|
+
resolveFetch(mockResponse([{ url: 'wss://r1.coalesce.livekit.cloud' }], 'max-age=300'));
|
|
149
|
+
const [o1, o2] = await Promise.all([p1, p2]);
|
|
150
|
+
|
|
151
|
+
expect(o1).toEqual(['https://r1.coalesce.livekit.cloud']);
|
|
152
|
+
expect(o2).toEqual(o1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('serves later callers from the cache within the TTL', async () => {
|
|
156
|
+
const fetchSpy = vi
|
|
157
|
+
.spyOn(globalThis, 'fetch')
|
|
158
|
+
.mockResolvedValue(mockResponse([{ url: 'wss://r.cached.livekit.cloud' }], 'max-age=300'));
|
|
159
|
+
|
|
160
|
+
const origin = new URL('https://primary.cached.livekit.cloud');
|
|
161
|
+
const first = await regionOrigins(origin, {});
|
|
162
|
+
const second = await regionOrigins(origin, {});
|
|
163
|
+
|
|
164
|
+
expect(first).toEqual(['https://r.cached.livekit.cloud']);
|
|
165
|
+
expect(second).toEqual(first);
|
|
166
|
+
// The second call is served from the cache, so no second fetch.
|
|
167
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not cache when the TTL is zero (max-age=0)', async () => {
|
|
171
|
+
const fetchSpy = vi
|
|
172
|
+
.spyOn(globalThis, 'fetch')
|
|
173
|
+
.mockResolvedValue(mockResponse([{ url: 'wss://r.nocache.livekit.cloud' }], 'max-age=0'));
|
|
174
|
+
|
|
175
|
+
const origin = new URL('https://primary.nocache.livekit.cloud');
|
|
176
|
+
await regionOrigins(origin, {});
|
|
177
|
+
await regionOrigins(origin, {});
|
|
178
|
+
|
|
179
|
+
// Nothing cached, so each call re-fetches.
|
|
180
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns an empty list when discovery fails and nothing is cached', async () => {
|
|
184
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'));
|
|
185
|
+
const origin = new URL('https://primary.error.livekit.cloud');
|
|
186
|
+
await expect(regionOrigins(origin, {})).resolves.toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|