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.
Files changed (101) hide show
  1. package/dist/AgentDispatchClient.cjs +5 -3
  2. package/dist/AgentDispatchClient.cjs.map +1 -1
  3. package/dist/AgentDispatchClient.js +5 -3
  4. package/dist/AgentDispatchClient.js.map +1 -1
  5. package/dist/ClientOptions.cjs.map +1 -1
  6. package/dist/ClientOptions.d.cts +12 -0
  7. package/dist/ClientOptions.d.ts +12 -0
  8. package/dist/ClientOptions.d.ts.map +1 -1
  9. package/dist/ConnectorClient.cjs +9 -5
  10. package/dist/ConnectorClient.cjs.map +1 -1
  11. package/dist/ConnectorClient.d.cts +6 -3
  12. package/dist/ConnectorClient.d.ts +6 -3
  13. package/dist/ConnectorClient.d.ts.map +1 -1
  14. package/dist/ConnectorClient.js +9 -5
  15. package/dist/ConnectorClient.js.map +1 -1
  16. package/dist/EgressClient.cjs +5 -3
  17. package/dist/EgressClient.cjs.map +1 -1
  18. package/dist/EgressClient.js +5 -3
  19. package/dist/EgressClient.js.map +1 -1
  20. package/dist/IngressClient.cjs +5 -3
  21. package/dist/IngressClient.cjs.map +1 -1
  22. package/dist/IngressClient.js +5 -3
  23. package/dist/IngressClient.js.map +1 -1
  24. package/dist/LiveKitAPI.cjs +80 -0
  25. package/dist/LiveKitAPI.cjs.map +1 -0
  26. package/dist/LiveKitAPI.d.cts +77 -0
  27. package/dist/LiveKitAPI.d.ts +77 -0
  28. package/dist/LiveKitAPI.d.ts.map +1 -0
  29. package/dist/LiveKitAPI.js +56 -0
  30. package/dist/LiveKitAPI.js.map +1 -0
  31. package/dist/RoomServiceClient.cjs +5 -3
  32. package/dist/RoomServiceClient.cjs.map +1 -1
  33. package/dist/RoomServiceClient.js +5 -3
  34. package/dist/RoomServiceClient.js.map +1 -1
  35. package/dist/ServiceBase.cjs +9 -9
  36. package/dist/ServiceBase.cjs.map +1 -1
  37. package/dist/ServiceBase.d.cts +20 -1
  38. package/dist/ServiceBase.d.ts +20 -1
  39. package/dist/ServiceBase.d.ts.map +1 -1
  40. package/dist/ServiceBase.js +9 -9
  41. package/dist/ServiceBase.js.map +1 -1
  42. package/dist/SipClient.cjs +40 -19
  43. package/dist/SipClient.cjs.map +1 -1
  44. package/dist/SipClient.d.cts +4 -2
  45. package/dist/SipClient.d.ts +4 -2
  46. package/dist/SipClient.d.ts.map +1 -1
  47. package/dist/SipClient.js +41 -20
  48. package/dist/SipClient.js.map +1 -1
  49. package/dist/TwirpRPC.cjs +135 -48
  50. package/dist/TwirpRPC.cjs.map +1 -1
  51. package/dist/TwirpRPC.d.cts +38 -2
  52. package/dist/TwirpRPC.d.ts +38 -2
  53. package/dist/TwirpRPC.d.ts.map +1 -1
  54. package/dist/TwirpRPC.js +140 -38
  55. package/dist/TwirpRPC.js.map +1 -1
  56. package/dist/dialTimeout.cjs +39 -0
  57. package/dist/dialTimeout.cjs.map +1 -0
  58. package/dist/dialTimeout.d.cts +21 -0
  59. package/dist/dialTimeout.d.ts +21 -0
  60. package/dist/dialTimeout.d.ts.map +1 -0
  61. package/dist/dialTimeout.js +13 -0
  62. package/dist/dialTimeout.js.map +1 -0
  63. package/dist/failover.cjs +139 -0
  64. package/dist/failover.cjs.map +1 -0
  65. package/dist/failover.d.cts +30 -0
  66. package/dist/failover.d.ts +30 -0
  67. package/dist/failover.d.ts.map +1 -0
  68. package/dist/failover.js +107 -0
  69. package/dist/failover.js.map +1 -0
  70. package/dist/index.cjs +6 -0
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.d.cts +3 -2
  73. package/dist/index.d.ts +3 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +4 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/version.cjs +29 -0
  78. package/dist/version.cjs.map +1 -0
  79. package/dist/version.d.cts +3 -0
  80. package/dist/version.d.ts +3 -0
  81. package/dist/version.d.ts.map +1 -0
  82. package/dist/version.js +5 -0
  83. package/dist/version.js.map +1 -0
  84. package/package.json +2 -2
  85. package/src/AgentDispatchClient.ts +5 -5
  86. package/src/ClientOptions.ts +12 -0
  87. package/src/ConnectorClient.ts +20 -11
  88. package/src/EgressClient.ts +5 -5
  89. package/src/IngressClient.ts +5 -5
  90. package/src/LiveKitAPI.ts +126 -0
  91. package/src/RoomServiceClient.ts +5 -5
  92. package/src/ServiceBase.ts +35 -4
  93. package/src/SipClient.ts +55 -23
  94. package/src/TwirpRPC.test.ts +42 -0
  95. package/src/TwirpRPC.ts +187 -39
  96. package/src/dialTimeout.test.ts +41 -0
  97. package/src/dialTimeout.ts +37 -0
  98. package/src/failover.test.ts +188 -0
  99. package/src/failover.ts +170 -0
  100. package/src/index.ts +3 -1
  101. 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 = 60;
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 TwirpError extends Error {
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 url = new URL(path, this.host);
81
- const init: RequestInit = {
82
- method: 'POST',
83
- headers: {
84
- 'Content-Type': 'application/json;charset=UTF-8',
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
- if (timeout) {
91
- init.signal = AbortSignal.timeout(timeout * 1000);
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
- const response = await fetch(url, init);
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
- if (!response.ok) {
97
- const isJson = response.headers.get('content-type') === 'application/json';
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
- if (isJson) {
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
- // parsing went wrong, no op and we keep default error message
118
- console.debug(`Error when trying to parse error message, using defaults`, e);
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
- throw new TwirpError(response.statusText, errorMessage, response.status, errorCode, metadata);
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
- const camelcaseKeys = await import('camelcase-keys').then((mod) => mod.default);
126
- return camelcaseKeys(parsedResp, { deep: true });
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
+ });