diff --git a/.changeset/livekitapi-unified-client.md b/.changeset/livekitapi-unified-client.md new file mode 100644 index 00000000..7a24c360 --- /dev/null +++ b/.changeset/livekitapi-unified-client.md @@ -0,0 +1,5 @@ +--- +'livekit-server-sdk': minor +--- + +Add `LiveKitAPI`, a unified entry point that exposes every server API through a property (`api.room`, `api.egress`, `api.ingress`, `api.sip`, `api.agentDispatch`, `api.connector`). It can be constructed with an API key and secret, or with a pre-signed token (no secret required, so it can run client-side), and falls back to the `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `LIVEKIT_TOKEN` environment variables. Individual clients also accept a `token` via `ClientOptions`. diff --git a/.changeset/sip-call-error.md b/.changeset/sip-call-error.md new file mode 100644 index 00000000..1a2a0dc6 --- /dev/null +++ b/.changeset/sip-call-error.md @@ -0,0 +1,5 @@ +--- +'livekit-server-sdk': minor +--- + +Add `SipCallError`, a `TwirpError` subclass thrown by `SipClient.createSipParticipant` / `transferSipParticipant` when a call fails with a SIP status. It exposes the SIP response code and reason as `sipStatusCode` / `sipStatus` getters, while other error metadata remains available via `metadata`. diff --git a/.github/workflows/test-api.yml b/.github/workflows/test-api.yml index a5f456ba..268e19ed 100644 --- a/.github/workflows/test-api.yml +++ b/.github/workflows/test-api.yml @@ -39,6 +39,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Generate version file + run: pnpm --filter="livekit-server-sdk" run prebuild + - name: Wait for mock server run: | for i in $(seq 1 30); do diff --git a/.gitignore b/.gitignore index 6f801994..936099c0 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ packages/*/docs # generated version file packages/livekit-rtc/src/version.ts +packages/livekit-server-sdk/src/version.ts diff --git a/.prettierignore b/.prettierignore index 62001cf3..23124654 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,5 @@ node_modules packages/livekit-rtc/src/napi packages/livekit-rtc/src/proto packages/livekit-rtc/target -packages/livekit-rtc/src/version.ts \ No newline at end of file +packages/livekit-rtc/src/version.ts +packages/livekit-server-sdk/src/version.ts \ No newline at end of file diff --git a/packages/livekit-server-sdk/package.json b/packages/livekit-server-sdk/package.json index 2b1fa7ed..fa2f2612 100644 --- a/packages/livekit-server-sdk/package.json +++ b/packages/livekit-server-sdk/package.json @@ -30,6 +30,7 @@ "src" ], "scripts": { + "prebuild": "node -p \"'export const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", "build": "tsup --onSuccess \"tsc -p tsconfig.json --declaration --emitDeclarationOnly --skipLibCheck\"", "build:watch": "tsc --watch", "build-docs": "typedoc", diff --git a/packages/livekit-server-sdk/src/AgentDispatchClient.ts b/packages/livekit-server-sdk/src/AgentDispatchClient.ts index 1df7c409..b077cc48 100644 --- a/packages/livekit-server-sdk/src/AgentDispatchClient.ts +++ b/packages/livekit-server-sdk/src/AgentDispatchClient.ts @@ -39,7 +39,7 @@ export class AgentDispatchClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, diff --git a/packages/livekit-server-sdk/src/ClientOptions.ts b/packages/livekit-server-sdk/src/ClientOptions.ts index d3e45720..02cf0b13 100644 --- a/packages/livekit-server-sdk/src/ClientOptions.ts +++ b/packages/livekit-server-sdk/src/ClientOptions.ts @@ -15,4 +15,11 @@ export type ClientOptions = { * Cloud hosts only). Defaults to true; set to false to disable. */ failover?: boolean; + /** + * A pre-signed access token, sent verbatim as the Authorization header on + * every request instead of signing one per call from an API key and secret. + * The token must already carry the grants for the calls it's used with; since + * it needs no secret, the client can run client-side. + */ + token?: string; }; diff --git a/packages/livekit-server-sdk/src/ConnectorClient.ts b/packages/livekit-server-sdk/src/ConnectorClient.ts index 8e2eb7a0..dbd0fe3c 100644 --- a/packages/livekit-server-sdk/src/ConnectorClient.ts +++ b/packages/livekit-server-sdk/src/ConnectorClient.ts @@ -84,14 +84,11 @@ export interface AcceptWhatsAppCallOptions { participantAttributes?: { [key: string]: string }; /** Optional - Country where the call terminates as ISO 3166-1 alpha-2 */ destinationCountry?: string; - /** Optional - Max time in seconds for the callee to answer the call */ - ringingTimeout?: number; - /** Optional - Wait for the call to be answered before returning */ + /** Optional - Wait until the inbound party joins before returning. */ waitUntilAnswered?: boolean; /** - * Optional - Request timeout in seconds. When `waitUntilAnswered` is set, - * defaults to a longer value (dialing takes time) and is raised, if needed, - * to stay above `ringingTimeout`; otherwise the client default applies. + * Optional - Request timeout in seconds. When `waitUntilAnswered` is set it + * defaults to the standard ring window; otherwise the client default applies. */ timeout?: number; } @@ -129,7 +126,7 @@ export class ConnectorClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, @@ -207,17 +204,12 @@ export class ConnectorClient extends ServiceBase { participantMetadata, participantAttributes: options.participantAttributes, destinationCountry, - ringingTimeout: options.ringingTimeout - ? new Duration({ seconds: BigInt(options.ringingTimeout) }) - : undefined, waitUntilAnswered: options.waitUntilAnswered, }).toJson(); - // Accept can block until the call is answered, so default the request timeout - // to the standard ring window. The caller overrides it via `timeout` and - // should set it above the ringing_timeout passed to dialWhatsAppCall; the - // two calls are separate, so the SDK can't derive it. Non-waiting returns - // promptly and uses the client default. + // When waiting for the inbound party to join, the request can block, so + // default the timeout to the standard ring window; otherwise the client + // default applies. const timeout = options.waitUntilAnswered ? (options.timeout ?? DEFAULT_RINGING_TIMEOUT_SECONDS) : options.timeout; diff --git a/packages/livekit-server-sdk/src/EgressClient.ts b/packages/livekit-server-sdk/src/EgressClient.ts index 69dec99c..9023dac1 100644 --- a/packages/livekit-server-sdk/src/EgressClient.ts +++ b/packages/livekit-server-sdk/src/EgressClient.ts @@ -141,7 +141,7 @@ export class EgressClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, diff --git a/packages/livekit-server-sdk/src/IngressClient.ts b/packages/livekit-server-sdk/src/IngressClient.ts index 75934ee9..c13b0e94 100644 --- a/packages/livekit-server-sdk/src/IngressClient.ts +++ b/packages/livekit-server-sdk/src/IngressClient.ts @@ -128,7 +128,7 @@ export class IngressClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, diff --git a/packages/livekit-server-sdk/src/LiveKitAPI.ts b/packages/livekit-server-sdk/src/LiveKitAPI.ts new file mode 100644 index 00000000..cbf44105 --- /dev/null +++ b/packages/livekit-server-sdk/src/LiveKitAPI.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { AgentDispatchClient } from './AgentDispatchClient.js'; +import type { ClientOptions } from './ClientOptions.js'; +import { ConnectorClient } from './ConnectorClient.js'; +import { EgressClient } from './EgressClient.js'; +import { IngressClient } from './IngressClient.js'; +import { RoomServiceClient } from './RoomServiceClient.js'; +import { SipClient } from './SipClient.js'; + +/** Server host and non-auth options, shared by both authentication modes. */ +interface LiveKitAPICommonOptions { + /** Server host, including protocol. Falls back to the `LIVEKIT_URL` env var. */ + host?: string; + /** Optional timeout, in seconds, for all server requests. */ + requestTimeout?: number; + /** + * Whether to fail over to alternative regions on retryable errors (LiveKit + * Cloud hosts only). Defaults to true; set to false to disable. + */ + failover?: boolean; +} + +/** API key and secret authentication (recommended for backend use). */ +interface ApiKeyAuth { + /** API key. Falls back to the `LIVEKIT_API_KEY` env var. */ + apiKey?: string; + /** API secret. Falls back to the `LIVEKIT_API_SECRET` env var. */ + secret?: string; + token?: never; +} + +/** Pre-signed token authentication (client-side use; no secret required). */ +interface TokenAuth { + /** Pre-signed token, sent verbatim. Falls back to the `LIVEKIT_TOKEN` env var. */ + token: string; + apiKey?: never; + secret?: never; +} + +/** + * Options for {@link LiveKitAPI}. Provide either an `apiKey` and `secret` or a + * pre-signed `token` — the two modes are mutually exclusive. Any omitted value + * falls back to its environment variable (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, + * `LIVEKIT_API_SECRET`, `LIVEKIT_TOKEN`). + */ +export type LiveKitAPIOptions = LiveKitAPICommonOptions & (ApiKeyAuth | TokenAuth); + +/** + * A single entry point to every LiveKit server API, exposing each service + * through a property, e.g. `api.room.createRoom(...)`. + * + * @example + * ```ts + * const api = new LiveKitAPI({ apiKey, secret }); // or new LiveKitAPI() to read from env + * await api.room.createRoom({ name: 'my-room' }); + * ``` + */ +export class LiveKitAPI { + private readonly _room: RoomServiceClient; + + private readonly _egress: EgressClient; + + private readonly _ingress: IngressClient; + + private readonly _sip: SipClient; + + private readonly _agentDispatch: AgentDispatchClient; + + private readonly _connector: ConnectorClient; + + /** + * @param options - server host, credentials, and client options; each value + * falls back to its environment variable when omitted. + */ + constructor(options: LiveKitAPIOptions = {}) { + const host = options.host || process.env.LIVEKIT_URL; + if (!host) { + throw new Error('host is required (pass it or set LIVEKIT_URL)'); + } + const { apiKey, secret } = options; + // Only fall back to LIVEKIT_TOKEN when no explicit credentials were given, so + // an ambient token can't silently override a passed-in api key and secret. + const token = options.token || (apiKey || secret ? undefined : process.env.LIVEKIT_TOKEN); + if (!token && !(apiKey ?? process.env.LIVEKIT_API_KEY)) { + throw new Error('either a token or an API key and secret are required'); + } + + const clientOptions: ClientOptions = { + requestTimeout: options.requestTimeout, + failover: options.failover, + token, + }; + this._room = new RoomServiceClient(host, apiKey, secret, clientOptions); + this._egress = new EgressClient(host, apiKey, secret, clientOptions); + this._ingress = new IngressClient(host, apiKey, secret, clientOptions); + this._sip = new SipClient(host, apiKey, secret, clientOptions); + this._agentDispatch = new AgentDispatchClient(host, apiKey, secret, clientOptions); + this._connector = new ConnectorClient(host, apiKey, secret, clientOptions); + } + + get room(): RoomServiceClient { + return this._room; + } + + get egress(): EgressClient { + return this._egress; + } + + get ingress(): IngressClient { + return this._ingress; + } + + get sip(): SipClient { + return this._sip; + } + + get agentDispatch(): AgentDispatchClient { + return this._agentDispatch; + } + + get connector(): ConnectorClient { + return this._connector; + } +} diff --git a/packages/livekit-server-sdk/src/RoomServiceClient.ts b/packages/livekit-server-sdk/src/RoomServiceClient.ts index 59202dca..09da51b6 100644 --- a/packages/livekit-server-sdk/src/RoomServiceClient.ts +++ b/packages/livekit-server-sdk/src/RoomServiceClient.ts @@ -131,7 +131,7 @@ export class RoomServiceClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, diff --git a/packages/livekit-server-sdk/src/ServiceBase.ts b/packages/livekit-server-sdk/src/ServiceBase.ts index 72590ec7..fb5286a5 100644 --- a/packages/livekit-server-sdk/src/ServiceBase.ts +++ b/packages/livekit-server-sdk/src/ServiceBase.ts @@ -4,6 +4,20 @@ import { AccessToken } from './AccessToken.js'; import type { SIPGrant, VideoGrant } from './grants.js'; +/** + * Authentication options for a service client. + */ +export interface ServiceBaseOptions { + /** API Key. */ + apiKey?: string; + /** API Secret. */ + secret?: string; + /** Token TTL. Defaults to `10m`. */ + ttl?: string; + /** Pre-signed token; sent verbatim, skipping per-call signing. */ + token?: string; +} + /** * Utilities to handle authentication */ @@ -12,20 +26,37 @@ export class ServiceBase { private readonly secret?: string; + private readonly token?: string; + private readonly ttl: string; /** + * @param options - authentication options + */ + constructor(options?: ServiceBaseOptions); + /** + * @deprecated pass a {@link ServiceBaseOptions} object instead. * @param apiKey - API Key. * @param secret - API Secret. * @param ttl - token TTL */ - constructor(apiKey?: string, secret?: string, ttl?: string) { - this.apiKey = apiKey; - this.secret = secret; - this.ttl = ttl || '10m'; + constructor(apiKey?: string, secret?: string, ttl?: string); + constructor(apiKeyOrOptions?: string | ServiceBaseOptions, secret?: string, ttl?: string) { + const options: ServiceBaseOptions = + typeof apiKeyOrOptions === 'object' + ? apiKeyOrOptions + : { apiKey: apiKeyOrOptions, secret, ttl }; + this.apiKey = options.apiKey; + this.secret = options.secret; + this.ttl = options.ttl || '10m'; + this.token = options.token; } async authHeader(grant: VideoGrant, sip?: SIPGrant): Promise> { + // A pre-signed token is sent verbatim; the caller is responsible for its grants. + if (this.token) { + return { Authorization: `Bearer ${this.token}` }; + } const at = new AccessToken(this.apiKey, this.secret, { ttl: this.ttl }); if (grant) { at.addGrant(grant); diff --git a/packages/livekit-server-sdk/src/SipClient.ts b/packages/livekit-server-sdk/src/SipClient.ts index 8314cdef..7e0dae55 100644 --- a/packages/livekit-server-sdk/src/SipClient.ts +++ b/packages/livekit-server-sdk/src/SipClient.ts @@ -45,9 +45,18 @@ import { import type { ClientOptions } from './ClientOptions.js'; import { ServiceBase } from './ServiceBase.js'; import type { Rpc } from './TwirpRPC.js'; -import { TwirpRpc, livekitPackage } from './TwirpRPC.js'; +import { ServerError, SipCallError, TwirpRpc, livekitPackage } from './TwirpRPC.js'; import { DEFAULT_RINGING_TIMEOUT_SECONDS, dialRequestTimeout } from './dialTimeout.js'; +// A SIP dialing failure carries a SIP status in its error metadata; surface it as +// a SipCallError so callers can branch on the SIP code. +function asSipCallError(e: unknown): unknown { + if (e instanceof ServerError && e.metadata && 'sip_status_code' in e.metadata) { + return SipCallError.fromServerError(e); + } + return e; +} + const svc = 'SIP'; /** @@ -251,7 +260,7 @@ export class SipClient extends ServiceBase { * @param options - client options */ constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) { - super(apiKey, secret); + super({ apiKey, secret, token: options?.token }); this.rpc = new TwirpRpc(host, livekitPackage, { requestTimeout: options?.requestTimeout, failover: options?.failover, @@ -803,14 +812,18 @@ export class SipClient extends ServiceBase { media: opts.media, }).toJson(); - const data = await this.rpc.request( - svc, - 'CreateSIPParticipant', - req, - await this.authHeader({}, { call: true }), - opts.timeout, - ); - return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true }); + try { + const data = await this.rpc.request( + svc, + 'CreateSIPParticipant', + req, + await this.authHeader({}, { call: true }), + opts.timeout, + ); + return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true }); + } catch (e) { + throw asSipCallError(e); + } } /** @@ -848,12 +861,16 @@ export class SipClient extends ServiceBase { : undefined, }).toJson(); - await this.rpc.request( - svc, - 'TransferSIPParticipant', - req, - await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }), - opts.timeout, - ); + try { + await this.rpc.request( + svc, + 'TransferSIPParticipant', + req, + await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }), + opts.timeout, + ); + } catch (e) { + throw asSipCallError(e); + } } } diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts new file mode 100644 index 00000000..cc51f171 --- /dev/null +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { ServerError, SipCallError } from './TwirpRPC.js'; + +describe('SipCallError', () => { + it('renders the SIP status, Twirp code, and extra metadata', () => { + const err = SipCallError.fromServerError( + new ServerError( + 'Too Many Requests', + 'twirp error: sip status 486', + 429, + 'resource_exhausted', + { + sip_status_code: '486', + sip_status: 'Busy Here', + error_details: 'CAgS...base64...', + region: 'us-east', + }, + ), + ); + + expect(err).toBeInstanceOf(ServerError); + expect(err.name).toBe('SipCallError'); + expect(err.sipStatusCode).toBe(486); + expect(err.sipStatus).toBe('Busy Here'); + + const printed = err.toString(); + expect(printed).toContain('SipCallError'); + expect(printed).toContain('486'); + expect(printed).toContain('Busy Here'); + expect(printed).toContain('resource_exhausted'); + expect(printed).toContain('region=us-east'); // other metadata is surfaced + expect(printed).not.toContain('error_details'); // opaque blob is omitted + }); + + it('falls back to the original message when there is no SIP status', () => { + const err = SipCallError.fromServerError(new ServerError('Internal', 'boom', 500, 'internal')); + expect(err.message).toBe('boom'); + }); +}); diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index b22d0bff..e3dac2b8 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -10,6 +10,11 @@ import { regionOrigins, sleep, } from './failover.js'; +import { SDK_VERSION } from './version.js'; + +// Identifies the SDK and version to the server on every request. Browsers forbid +// setting User-Agent via fetch and silently drop it; Node honors it. +const USER_AGENT = `livekit-server-sdk-node/${SDK_VERSION}`; // twirp RPC adapter for client implementation @@ -40,7 +45,7 @@ export interface Rpc { ): Promise; } -export class TwirpError extends Error { +export class ServerError extends Error { status: number; code?: string; metadata?: Record; @@ -60,6 +65,68 @@ export class TwirpError extends Error { } } +/** @deprecated use {@link ServerError} */ +export const TwirpError = ServerError; +/** @deprecated use {@link ServerError} */ +export type TwirpError = ServerError; + +/** + * A {@link ServerError} from a SIP dialing call (`createSipParticipant` / + * `transferSipParticipant`) that failed with a SIP response status. The SIP code + * and reason are exposed as getters; any other error metadata remains available + * via {@link ServerError.metadata}. + */ +export class SipCallError extends ServerError { + constructor( + name: string, + message: string, + status: number, + code?: string, + metadata?: Record, + ) { + super(name, SipCallError.describe(message, code, metadata), status, code, metadata); + this.name = 'SipCallError'; + } + + /** The SIP response code of the failed call, e.g. 486 (Busy Here). */ + get sipStatusCode(): number | undefined { + const raw = this.metadata?.sip_status_code; + return raw !== undefined ? Number(raw) : undefined; + } + + /** The SIP reason phrase of the failed call, e.g. "Busy Here". */ + get sipStatus(): string | undefined { + return this.metadata?.sip_status; + } + + /** Builds a SipCallError from a ServerError, preserving its code and metadata. */ + static fromServerError(err: ServerError): SipCallError { + return new SipCallError(err.name, err.message, err.status, err.code, err.metadata); + } + + // describe renders a clear message: the SIP status, the error code, and any + // other metadata the server attached. Falls back to the raw message when the + // error carries no SIP status. + private static describe(fallback: string, code?: string, metadata?: Record) { + const sipCode = metadata?.sip_status_code; + if (!sipCode) { + return fallback; + } + const reason = metadata?.sip_status; + let msg = `SIP call failed: ${sipCode}${reason ? ` ${reason}` : ''}`; + if (code) { + msg += ` (${code})`; + } + const extra = Object.entries(metadata ?? {}) + .filter(([k]) => k !== 'sip_status_code' && k !== 'sip_status' && k !== 'error_details') + .map(([k, v]) => `${k}=${v}`); + if (extra.length) { + msg += ` [${extra.join(', ')}]`; + } + return msg; + } +} + /** * JSON based Twirp V7 RPC */ @@ -110,6 +177,7 @@ export class TwirpRpc { const body = JSON.stringify(data); const requestHeaders = { 'Content-Type': 'application/json;charset=UTF-8', + 'User-Agent': USER_AGENT, ...headers, }; diff --git a/packages/livekit-server-sdk/src/dialTimeout.ts b/packages/livekit-server-sdk/src/dialTimeout.ts index 090081b7..9a3bc14d 100644 --- a/packages/livekit-server-sdk/src/dialTimeout.ts +++ b/packages/livekit-server-sdk/src/dialTimeout.ts @@ -2,10 +2,10 @@ // // SPDX-License-Identifier: Apache-2.0 -// Shared request-timeout handling for calls that dial a phone and wait for an -// answer (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp -// AcceptWhatsAppCall). These take longer than a normal API call, and the -// request must outlast ringing or it would abort before the call is answered. +// Shared request-timeout handling for calls that may block until a call is +// answered (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp +// AcceptWhatsAppCall). These take longer than a normal API call, and the request +// must outlast the wait or it would abort before the call is answered. /** * Ring window (seconds) assumed when a request doesn't set a ringing timeout; diff --git a/packages/livekit-server-sdk/src/index.ts b/packages/livekit-server-sdk/src/index.ts index d358f275..46cd38a2 100644 --- a/packages/livekit-server-sdk/src/index.ts +++ b/packages/livekit-server-sdk/src/index.ts @@ -78,8 +78,9 @@ export * from './ConnectorClient.js'; export * from './EgressClient.js'; export * from './grants.js'; export * from './IngressClient.js'; +export * from './LiveKitAPI.js'; export * from './RoomServiceClient.js'; export * from './SipClient.js'; -export { TwirpError } from './TwirpRPC.js'; +export { ServerError, SipCallError, TwirpError } from './TwirpRPC.js'; export type { ClientOptions } from './ClientOptions.js'; export * from './WebhookReceiver.js'; diff --git a/packages/livekit-server-sdk/test/api/failover.test.ts b/packages/livekit-server-sdk/test/api/failover.test.ts index 636b3d1d..4edc1971 100644 --- a/packages/livekit-server-sdk/test/api/failover.test.ts +++ b/packages/livekit-server-sdk/test/api/failover.test.ts @@ -1,31 +1,18 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 - -// API tests against the shared mock LiveKit API server (livekit/livekit -// cmd/test-server). Point them at a running instance with LK_TEST_SERVER_URL -// (default http://127.0.0.1:9999); they skip when no server is reachable. In CI -// the server is booted as a Docker container. -// -// See cmd/test-server/README.md for the X-Lk-Mock-* control protocol. These -// tests drive TwirpRpc.request() directly because the public service methods do -// not expose per-call headers. +// Region-failover tests against the shared mock LiveKit API server. See mock.ts +// for setup and the X-Lk-Mock JSON control protocol. These drive +// TwirpRpc.request() directly because failover relies on internal test-only +// knobs (failoverForce/failoverBackoffMs) the public methods don't expose. import { describe, expect, it } from 'vitest'; -import { TwirpError, TwirpRpc, livekitPackage } from '../../src/TwirpRPC.js'; - -const BASE = process.env.LK_TEST_SERVER_URL ?? 'http://127.0.0.1:9999'; - -let reachable = false; -try { - reachable = (await fetch(`${BASE}/settings/regions`)).ok; -} catch { - reachable = false; -} +import { ServerError, TwirpRpc, livekitPackage } from '../../src/TwirpRPC.js'; +import { BASE, type MockControl, reachable } from './mock.js'; // failoverForce bypasses the cloud-host check (the mock is on 127.0.0.1) and a // tiny backoff keeps the tests fast — both are internal, test-only knobs. const call = ( - directives: Record, + mock: MockControl = {}, { failover = true, force = true }: { failover?: boolean; force?: boolean } = {}, ) => { const rpc = new TwirpRpc(BASE, livekitPackage, { @@ -33,59 +20,55 @@ const call = ( failoverForce: force, failoverBackoffMs: 1, }); - return rpc.request('RoomService', 'CreateRoom', {}, { - authorization: 'Bearer test-token', - // These tests exercise failover, not authz; skip the mock's permission check. - 'x-lk-mock-skip-auth': 'true', - ...directives, - }); + return rpc.request( + 'RoomService', + 'CreateRoom', + {}, + { + authorization: 'Bearer test-token', + // These tests exercise failover, not authz; skip the mock's permission check. + 'X-Lk-Mock': JSON.stringify({ skipAuth: true, ...mock }), + }, + ); }; (reachable ? describe : describe.skip)('region failover', () => { it('succeeds on the primary when healthy', async () => { - await expect(call({})).resolves.toBeDefined(); + await expect(call()).resolves.toBeDefined(); }); it('fails over to a healthy region when the primary is down', async () => { - await expect(call({ 'x-lk-mock-fail-regions': '0' })).resolves.toBeDefined(); + await expect(call({ failRegions: [0] })).resolves.toBeDefined(); }); it('fails over to region 2 on the third attempt', async () => { - await expect(call({ 'x-lk-mock-fail-regions': '0,1' })).resolves.toBeDefined(); + await expect(call({ failRegions: [0, 1] })).resolves.toBeDefined(); }); it('surfaces an error when all regions are down', async () => { - await expect(call({ 'x-lk-mock-fail-regions': '0,1,2,3' })).rejects.toThrow(TwirpError); + await expect(call({ failRegions: [0, 1, 2, 3] })).rejects.toThrow(ServerError); }); it('does not retry a 4xx', async () => { - await expect( - call({ 'x-lk-mock-fail-regions': '0', 'x-lk-mock-fail-status': '400' }), - ).rejects.toMatchObject({ code: 'invalid_argument' }); + await expect(call({ failRegions: [0], failStatus: 400 })).rejects.toMatchObject({ + code: 'invalid_argument', + }); }); it('fails over on a transport error', async () => { - await expect( - call({ 'x-lk-mock-fail-regions': '0', 'x-lk-mock-fail-mode': 'drop' }), - ).resolves.toBeDefined(); + await expect(call({ failRegions: [0], failMode: 'drop' })).resolves.toBeDefined(); }); it('surfaces the original error when region discovery is unreachable', async () => { - await expect( - call({ 'x-lk-mock-fail-regions': '0', 'x-lk-mock-regions-status': '500' }), - ).rejects.toThrow(TwirpError); + await expect(call({ failRegions: [0], regionsStatus: 500 })).rejects.toThrow(ServerError); }); it('does not fail over for a non-cloud host (cloud-gated)', async () => { // failover enabled but not forced; 127.0.0.1 is not a cloud host. - await expect(call({ 'x-lk-mock-fail-regions': '0' }, { force: false })).rejects.toThrow( - TwirpError, - ); + await expect(call({ failRegions: [0] }, { force: false })).rejects.toThrow(ServerError); }); it('does not fail over when disabled', async () => { - await expect(call({ 'x-lk-mock-fail-regions': '0' }, { failover: false })).rejects.toThrow( - TwirpError, - ); + await expect(call({ failRegions: [0] }, { failover: false })).rejects.toThrow(ServerError); }); }); diff --git a/packages/livekit-server-sdk/test/api/livekitapi.test.ts b/packages/livekit-server-sdk/test/api/livekitapi.test.ts new file mode 100644 index 00000000..09653147 --- /dev/null +++ b/packages/livekit-server-sdk/test/api/livekitapi.test.ts @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// API tests that drive the unified LiveKitAPI against the mock LiveKit server. +// See mock.ts for setup. Because the mock enforces the same per-method grants as +// the real server, a call that resolves also proves the SDK attached the right +// grants automatically. The smoke tests fully populate each request so they +// double as a reference for a complete call and exercise field serialization. +import { + ConnectTwilioCallRequest_TwilioCallDirection, + DataPacket_Kind, + DirectFileOutput, + DisconnectWhatsAppCallRequest_DisconnectReason, + EncodedFileOutput, + EncodedFileType, + IngressAudioEncodingPreset, + IngressAudioOptions, + IngressInput, + IngressVideoEncodingPreset, + IngressVideoOptions, + JobRestartPolicy, + RoomAgentDispatch, + S3Upload, + SIPDispatchRule, + SIPDispatchRuleDirect, + SIPDispatchRuleInfo, + SIPHeaderOptions, + SIPInboundTrunkInfo, + SIPMediaConfig, + SIPMediaEncryption, + SIPOutboundTrunkInfo, + SIPTransport, + SegmentedFileOutput, + SegmentedFileProtocol, + SessionDescription, + StreamOutput, + StreamProtocol, +} from '@livekit/protocol'; +import { describe, expect, it } from 'vitest'; +import { AccessToken, LiveKitAPI, SipCallError, ServerError } from '../../src/index.js'; +import { BASE, TEST_API_KEY, TEST_API_SECRET, newApi, reachable, withMock } from './mock.js'; + +const d = reachable ? describe : describe.skip; + +d('LiveKitAPI', () => { + const api = newApi(); + + describe('room (smoke)', () => { + it('createRoom', () => + api.room.createRoom({ + name: 'test-room', + emptyTimeout: 300, + departureTimeout: 60, + maxParticipants: 50, + metadata: JSON.stringify({ scene: 'lobby' }), + minPlayoutDelay: 100, + maxPlayoutDelay: 2000, + syncStreams: true, + agents: [new RoomAgentDispatch({ agentName: 'greeter', metadata: '{"lang":"en"}' })], + })); + it('listRooms', () => api.room.listRooms(['test-room', 'lobby'])); + it('deleteRoom', () => api.room.deleteRoom('test-room')); + it('updateRoomMetadata', () => + api.room.updateRoomMetadata('test-room', JSON.stringify({ scene: 'intro' }))); + it('listParticipants', () => api.room.listParticipants('test-room')); + it('getParticipant', () => api.room.getParticipant('test-room', 'participant-42')); + it('removeParticipant', () => api.room.removeParticipant('test-room', 'participant-42')); + it('forwardParticipant', () => + api.room.forwardParticipant('test-room', 'participant-42', 'overflow-room')); + it('moveParticipant', () => + api.room.moveParticipant('test-room', 'participant-42', 'breakout-room')); + it('mutePublishedTrack', () => + api.room.mutePublishedTrack('test-room', 'participant-42', 'TR_video1', true)); + it('updateParticipant', () => + api.room.updateParticipant('test-room', 'participant-42', { + name: 'Alice', + metadata: JSON.stringify({ role: 'host' }), + attributes: { seat: '1A' }, + permission: { canPublish: true, canSubscribe: true, canPublishData: true }, + })); + it('updateSubscriptions', () => + api.room.updateSubscriptions('test-room', 'participant-42', ['TR_video1'], true)); + it('sendData', () => + api.room.sendData( + 'test-room', + new TextEncoder().encode('hello world'), + DataPacket_Kind.RELIABLE, + { topic: 'chat', destinationIdentities: ['participant-42'] }, + )); + }); + + describe('egress (smoke)', () => { + const s3 = () => ({ + case: 's3' as const, + value: new S3Upload({ bucket: 'recordings', region: 'us-east-1' }), + }); + it('startRoomCompositeEgress', () => + api.egress.startRoomCompositeEgress( + 'test-room', + { + file: new EncodedFileOutput({ + fileType: EncodedFileType.MP4, + filepath: 'room.mp4', + output: s3(), + }), + }, + { + layout: 'grid', + audioOnly: false, + videoOnly: false, + customBaseUrl: 'https://example.com/scene', + }, + )); + it('startWebEgress', () => + api.egress.startWebEgress( + 'https://example.com/scene', + { + stream: new StreamOutput({ + protocol: StreamProtocol.RTMP, + urls: ['rtmps://a.example.com/live/key'], + }), + }, + { awaitStartSignal: true }, + )); + it('startParticipantEgress', () => + api.egress.startParticipantEgress( + 'test-room', + 'participant-42', + { + file: new EncodedFileOutput({ + fileType: EncodedFileType.MP4, + filepath: 'participant.mp4', + output: s3(), + }), + }, + { screenShare: true }, + )); + it('startTrackCompositeEgress', () => + api.egress.startTrackCompositeEgress( + 'test-room', + { + segments: new SegmentedFileOutput({ + protocol: SegmentedFileProtocol.HLS_PROTOCOL, + filenamePrefix: 'segments/track', + playlistName: 'playlist.m3u8', + segmentDuration: 6, + output: s3(), + }), + }, + { audioTrackId: 'TR_audio1', videoTrackId: 'TR_video1' }, + )); + it('startTrackEgress', () => + api.egress.startTrackEgress( + 'test-room', + new DirectFileOutput({ filepath: 'track.mp4', output: s3() }), + 'TR_video1', + )); + it('updateLayout', () => api.egress.updateLayout('EG_abc123', 'speaker')); + it('updateStream', () => + api.egress.updateStream( + 'EG_abc123', + ['rtmps://b.example.com/live/key'], + ['rtmps://a.example.com/live/key'], + )); + it('listEgress', () => + api.egress.listEgress({ roomName: 'test-room', egressId: 'EG_abc123', active: true })); + it('stopEgress', () => api.egress.stopEgress('EG_abc123')); + }); + + describe('ingress (smoke)', () => { + it('createIngress', () => + api.ingress.createIngress(IngressInput.RTMP_INPUT, { + name: 'stream-input', + roomName: 'test-room', + participantIdentity: 'ingress-bot', + participantName: 'Live Stream', + participantMetadata: JSON.stringify({ source: 'rtmp' }), + enableTranscoding: true, + audio: new IngressAudioOptions({ + name: 'audio', + encodingOptions: { case: 'preset', value: IngressAudioEncodingPreset.OPUS_STEREO_96KBPS }, + }), + video: new IngressVideoOptions({ + name: 'video', + encodingOptions: { + case: 'preset', + value: IngressVideoEncodingPreset.H264_1080P_30FPS_3_LAYERS, + }, + }), + })); + it('updateIngress', () => + api.ingress.updateIngress('IN_abc123', { + name: 'stream-input-v2', + roomName: 'test-room', + participantIdentity: 'ingress-bot', + participantName: 'Live Stream', + enableTranscoding: true, + })); + it('listIngress', () => + api.ingress.listIngress({ roomName: 'test-room', ingressId: 'IN_abc123' })); + it('deleteIngress', () => api.ingress.deleteIngress('IN_abc123')); + }); + + describe('sip (smoke)', () => { + it('createSipInboundTrunk', () => + api.sip.createSipInboundTrunk('inbound-trunk', ['+15105550100'], { + metadata: JSON.stringify({ provider: 'telco' }), + allowedAddresses: ['203.0.113.0/24'], + allowedNumbers: ['+15105550111'], + authUsername: 'sip-user', + authPassword: 'sip-pass', + includeHeaders: SIPHeaderOptions.SIP_X_HEADERS, + krispEnabled: true, + media: new SIPMediaConfig({ encryption: SIPMediaEncryption.SIP_MEDIA_ENCRYPT_ALLOW }), + ringingTimeout: 30, + })); + it('createSipOutboundTrunk', () => + api.sip.createSipOutboundTrunk('outbound-trunk', 'sip.telco.example.com', ['+15105550100'], { + transport: SIPTransport.SIP_TRANSPORT_TLS, + metadata: JSON.stringify({ provider: 'telco' }), + authUsername: 'sip-user', + authPassword: 'sip-pass', + destinationCountry: 'US', + })); + it('updateSipInboundTrunk', () => + api.sip.updateSipInboundTrunk( + 'ST_abc123', + new SIPInboundTrunkInfo({ + sipTrunkId: 'ST_abc123', + name: 'inbound-v2', + numbers: ['+15105550100'], + authUsername: 'sip-user', + authPassword: 'sip-pass', + }), + )); + it('updateSipOutboundTrunk', () => + api.sip.updateSipOutboundTrunk( + 'ST_abc123', + new SIPOutboundTrunkInfo({ + sipTrunkId: 'ST_abc123', + name: 'outbound-v2', + address: 'sip.telco.example.com', + numbers: ['+15105550100'], + transport: SIPTransport.SIP_TRANSPORT_TLS, + }), + )); + it('listSipInboundTrunk', () => + api.sip.listSipInboundTrunk({ trunkIds: ['ST_abc123'], numbers: ['+15105550100'] })); + it('listSipOutboundTrunk', () => api.sip.listSipOutboundTrunk({ trunkIds: ['ST_abc123'] })); + it('listSipTrunk', () => api.sip.listSipTrunk()); + it('deleteSipTrunk', () => api.sip.deleteSipTrunk('ST_abc123')); + it('createSipDispatchRule', () => + api.sip.createSipDispatchRule( + { type: 'direct', roomName: 'support', pin: '1234' }, + { + name: 'direct-to-support', + metadata: JSON.stringify({ team: 'support' }), + trunkIds: ['ST_abc123'], + hidePhoneNumber: false, + attributes: { source: 'pstn' }, + }, + )); + it('updateSipDispatchRule', () => + api.sip.updateSipDispatchRule( + 'SDR_abc123', + new SIPDispatchRuleInfo({ + sipDispatchRuleId: 'SDR_abc123', + name: 'direct-v2', + trunkIds: ['ST_abc123'], + rule: new SIPDispatchRule({ + rule: { + case: 'dispatchRuleDirect', + value: new SIPDispatchRuleDirect({ roomName: 'support', pin: '1234' }), + }, + }), + }), + )); + it('listSipDispatchRule', () => + api.sip.listSipDispatchRule({ dispatchRuleIds: ['SDR_abc123'], trunkIds: ['ST_abc123'] })); + it('deleteSipDispatchRule', () => api.sip.deleteSipDispatchRule('SDR_abc123')); + }); + + describe('connector (smoke)', () => { + it('dialWhatsAppCall', () => + api.connector.dialWhatsAppCall({ + whatsappPhoneNumberId: '123456789012345', + whatsappToPhoneNumber: '+15105550100', + whatsappApiKey: 'wa-secret-key', + whatsappCloudApiVersion: '23.0', + roomName: 'test-room', + participantIdentity: 'whatsapp-caller', + participantName: 'WhatsApp Caller', + destinationCountry: 'US', + ringingTimeout: 30, + })); + it('acceptWhatsAppCall', () => + api.connector.acceptWhatsAppCall({ + whatsappPhoneNumberId: '123456789012345', + whatsappApiKey: 'wa-secret-key', + whatsappCloudApiVersion: '23.0', + whatsappCallId: 'wacid.HBgLABC', + sdp: new SessionDescription({ type: 'answer', sdp: 'v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n' }), + roomName: 'test-room', + participantIdentity: 'whatsapp-callee', + })); + it('connectWhatsAppCall', () => + api.connector.connectWhatsAppCall( + 'wacid.HBgLABC', + new SessionDescription({ type: 'offer', sdp: 'v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n' }), + )); + it('disconnectWhatsAppCall', () => + api.connector.disconnectWhatsAppCall( + 'wacid.HBgLABC', + 'wa-secret-key', + DisconnectWhatsAppCallRequest_DisconnectReason.BUSINESS_INITIATED, + )); + it('connectTwilioCall', () => + api.connector.connectTwilioCall({ + twilioCallDirection: ConnectTwilioCallRequest_TwilioCallDirection.INBOUND, + roomName: 'test-room', + participantIdentity: 'twilio-caller', + destinationCountry: 'US', + })); + }); + + describe('agentDispatch (smoke)', () => { + it('createDispatch', () => + api.agentDispatch.createDispatch('test-room', 'inbound-agent', { + metadata: JSON.stringify({ lang: 'en' }), + restartPolicy: JobRestartPolicy.JRP_ON_FAILURE, + })); + it('getDispatch', () => api.agentDispatch.getDispatch('AD_abc123', 'test-room')); + it('listDispatch', () => api.agentDispatch.listDispatch('test-room')); + it('deleteDispatch', () => api.agentDispatch.deleteDispatch('AD_abc123', 'test-room')); + }); + + // CreateRoom in depth: the mock echoes request fields into the response. + describe('createRoom (deep)', () => { + it('echoes request fields', async () => { + const room = await api.room.createRoom({ + name: 'echo-room', + metadata: JSON.stringify({ scene: 'lobby' }), + emptyTimeout: 300, + maxParticipants: 50, + }); + expect(room.name).toBe('echo-room'); + expect(room.metadata).toBe(JSON.stringify({ scene: 'lobby' })); + expect(room.emptyTimeout).toBe(300); + expect(room.maxParticipants).toBe(50); + expect(room.sid).not.toBe(''); // placeholder assigned by the mock + }); + + it('propagates twirp errors', async () => { + await expect( + withMock({ failRegions: [0], failStatus: 400, failTwirpCode: 'invalid_argument' }, () => + api.room.createRoom({ name: 'test-room' }), + ), + ).rejects.toMatchObject({ code: 'invalid_argument' }); + }); + }); + + // createSipParticipant/transferSipParticipant block until answered in the mock, + // so skip that wait with delayMs:0 except in the dial-timeout test. + describe('sip participant (deep)', () => { + it('createSipParticipant echoes fields', async () => { + const p = await api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room', { + participantIdentity: 'sip-caller', + participantName: 'SIP Caller', + participantMetadata: JSON.stringify({ source: 'pstn' }), + dtmf: '1234#', + playDialtone: true, + maxCallDuration: 3600, + }); + expect(p.roomName).toBe('test-room'); + expect(p.participantIdentity).toBe('sip-caller'); + }); + + it('createSipParticipant waitUntilAnswered', () => + withMock({ delayMs: 0 }, () => + api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room', { + waitUntilAnswered: true, + ringingTimeout: 2, + }), + )); + + it('transferSipParticipant', () => + withMock({ delayMs: 0 }, () => + api.sip.transferSipParticipant('test-room', 'sip-caller', 'tel:+15105550122', { + playDialtone: true, + ringingTimeout: 2, + }), + )); + }); + + // A pre-signed token is sent verbatim (no secret), enabling client-side use. + it('authenticates with a pre-signed token', async () => { + const at = new AccessToken(TEST_API_KEY, TEST_API_SECRET); + at.addGrant({ roomCreate: true }); + const token = await at.toJwt(); + + const tokenApi = new LiveKitAPI({ host: BASE, token }); + const room = await tokenApi.room.createRoom({ name: 'token-room' }); + expect(room.name).toBe('token-room'); + }); + + // An ambient LIVEKIT_TOKEN must not override an explicitly passed api key and + // secret — otherwise those requests would be sent with the (wrong) env token. + it('prefers explicit api key/secret over an ambient LIVEKIT_TOKEN', async () => { + const prev = process.env.LIVEKIT_TOKEN; + process.env.LIVEKIT_TOKEN = 'invalid-ambient-token'; + try { + const keyApi = new LiveKitAPI({ host: BASE, apiKey: TEST_API_KEY, secret: TEST_API_SECRET }); + const room = await keyApi.room.createRoom({ name: 'env-token-room' }); + expect(room.name).toBe('env-token-room'); + } finally { + if (prev === undefined) delete process.env.LIVEKIT_TOKEN; + else process.env.LIVEKIT_TOKEN = prev; + } + }); + + // SIP dialing must outlast ringing: when the answer takes longer than the dial + // budget (ringing timeout + margin) the call times out, while a prompt answer + // within the budget succeeds. + describe('sip dial timeout', () => { + it('times out when the answer exceeds the dial budget', async () => { + // ringing 1s -> ~3s request budget; the mock delays the answer past it. + const err = await withMock({ delayMs: 4000 }, () => + api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room', { + waitUntilAnswered: true, + ringingTimeout: 1, + }), + ).catch((e: unknown) => e); + expect((err as Error).name).toBe('TimeoutError'); + expect(err).not.toBeInstanceOf(ServerError); + }); + + it('succeeds within the dial budget', () => + withMock({ delayMs: 200 }, () => + api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room', { + waitUntilAnswered: true, + }), + )); + }); + + // A failed dial surfaces the SIP response status as a SipCallError, whose + // getters expose the SIP code/reason while the generic Twirp code is preserved. + describe('sip call errors', () => { + it('surfaces a busy signal (resource_exhausted)', async () => { + const err = await withMock({ sipStatus: { code: 486, status: 'Busy Here' } }, () => + api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room'), + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(SipCallError); + expect(err).toBeInstanceOf(ServerError); + const sipErr = err as SipCallError; + expect(sipErr.code).toBe('resource_exhausted'); + expect(sipErr.sipStatusCode).toBe(486); + expect(sipErr.sipStatus).toBe('Busy Here'); + // printable representation makes the failure clear + expect(sipErr.name).toBe('SipCallError'); + expect(String(sipErr)).toContain('486'); + expect(String(sipErr)).toContain('Busy Here'); + }); + + it('surfaces a carrier decline (permission_denied)', async () => { + const err = await withMock({ sipStatus: { code: 603 } }, () => + api.sip.createSipParticipant('ST_abc123', '+15105550100', 'test-room'), + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(SipCallError); + const sipErr = err as SipCallError; + expect(sipErr.code).toBe('permission_denied'); + expect(sipErr.sipStatusCode).toBe(603); + }); + + it('surfaces SIP errors from transferSipParticipant', async () => { + // delayMs:0 skips transfer's built-in answer latency so the failure is immediate. + const err = await withMock( + { delayMs: 0, sipStatus: { code: 486, status: 'Busy Here' } }, + () => api.sip.transferSipParticipant('test-room', 'sip-caller', 'tel:+15105550122'), + ).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(SipCallError); + expect((err as SipCallError).sipStatusCode).toBe(486); + }); + }); +}); diff --git a/packages/livekit-server-sdk/test/api/mock.ts b/packages/livekit-server-sdk/test/api/mock.ts new file mode 100644 index 00000000..c494ba70 --- /dev/null +++ b/packages/livekit-server-sdk/test/api/mock.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// Shared helpers for API tests against the mock LiveKit server (livekit/livekit +// cmd/test-server). Point them at a running instance with LK_TEST_SERVER_URL +// (default http://127.0.0.1:9999); tests skip when it is not reachable. In CI +// the server is booted as a Docker container. +import { LiveKitAPI } from '../../src/LiveKitAPI.js'; + +export const BASE = process.env.LK_TEST_SERVER_URL ?? 'http://127.0.0.1:9999'; + +// devkey/secret match `livekit-server --dev`, which the mock verifies against. +export const TEST_API_KEY = 'devkey'; +export const TEST_API_SECRET = 'secret'; + +export let reachable = false; +try { + reachable = (await fetch(`${BASE}/settings/regions`)).ok; +} catch { + reachable = false; +} + +/** + * The X-Lk-Mock JSON control header understood by the mock server (see + * cmd/test-server/config.go). Every field is optional; `{}` means "behave + * normally". + */ +export interface MockControl { + failRegions?: number[]; + failMode?: 'status' | 'drop' | 'delay'; + failStatus?: number; + failTwirpCode?: string; + delayMs?: number; + regionsStatus?: number; + response?: unknown; + skipAuth?: boolean; + /** Fail a SIP dial method with this SIP status (code + optional reason phrase). */ + sipStatus?: { code: number; status?: string }; +} + +export function newApi(): LiveKitAPI { + return new LiveKitAPI({ host: BASE, apiKey: TEST_API_KEY, secret: TEST_API_SECRET }); +} + +/** + * Runs `fn` with the given mock directives attached to every outgoing request as + * the X-Lk-Mock header. The SDK's public methods don't expose per-call headers, + * so this wraps the global fetch for the duration — a test-only shim. + */ +export async function withMock(cfg: MockControl, fn: () => Promise): Promise { + const real = globalThis.fetch; + const patched: typeof fetch = (input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Lk-Mock', JSON.stringify(cfg)); + return real(input, { ...init, headers }); + }; + globalThis.fetch = patched; + try { + return await fn(); + } finally { + globalThis.fetch = real; + } +} diff --git a/turbo.json b/turbo.json index 74d3222b..5f581fb1 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", - "globalEnv": ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "NODE_ENV", "LIVEKIT_DEBUG_LOG_ROOM_EVENTS", "EXPECT_BUN_RUNTIME"], + "globalEnv": ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "LIVEKIT_TOKEN", "NODE_ENV", "LIVEKIT_DEBUG_LOG_ROOM_EVENTS", "EXPECT_BUN_RUNTIME"], "tasks": { "build": { "dependsOn": ["^build"],