Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sip-dial-options-unmodified.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-server-sdk': patch
---

Stop `createSipParticipant` and `transferSipParticipant` from writing `ringingTimeout` and `timeout` into the options object passed in. Frozen options no longer throw.
71 changes: 71 additions & 0 deletions packages/livekit-server-sdk/src/SipClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CreateSipParticipantOptions, TransferSipParticipantOptions } from './SipClient.js';
import { SipClient } from './SipClient.js';

describe('SipClient dialing options', () => {
afterEach(() => {
vi.restoreAllMocks();
});

// Captures the JSON body and the request timeout of every API call.
function mockApi() {
const bodies: Record<string, unknown>[] = [];
const timeouts: number[] = [];
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => {
timeouts.push(ms);
return realTimeout(ms);
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
bodies.push(JSON.parse(init!.body as string));
return { ok: true, status: 200, json: async () => ({}) } as unknown as Response;
});
return { bodies, timeouts };
}

const client = () => new SipClient('http://localhost:7880', 'key', 'secret');

it('createSipParticipant does not modify the options passed in', async () => {
const { bodies, timeouts } = mockApi();
const opts: CreateSipParticipantOptions = { waitUntilAnswered: true };

await client().createSipParticipant('ST_trunk', '+15105550100', 'room', opts);

expect(opts).toEqual({ waitUntilAnswered: true });
expect(bodies[0]?.ringingTimeout).toBe('30s');
expect(timeouts).toEqual([32_000]);
});

it('createSipParticipant accepts frozen options', async () => {
const { bodies, timeouts } = mockApi();
const opts = Object.freeze({ waitUntilAnswered: true, ringingTimeout: 45 });

await client().createSipParticipant('ST_trunk', '+15105550100', 'room', opts);

expect(bodies[0]?.ringingTimeout).toBe('45s');
expect(timeouts).toEqual([47_000]);
});

it('transferSipParticipant does not modify the options passed in', async () => {
const { bodies, timeouts } = mockApi();
const opts: TransferSipParticipantOptions = { playDialtone: true };

await client().transferSipParticipant('room', 'caller', 'tel:+15105550100', opts);

expect(opts).toEqual({ playDialtone: true });
expect(bodies[0]?.ringingTimeout).toBe('30s');
expect(timeouts).toEqual([32_000]);
});

it('transferSipParticipant accepts frozen options', async () => {
const { timeouts } = mockApi();
const opts = Object.freeze({ playDialtone: true, timeout: 60 });

await client().transferSipParticipant('room', 'caller', 'tel:+15105550100', opts);

expect(timeouts).toEqual([60_000]);
});
});
28 changes: 16 additions & 12 deletions packages/livekit-server-sdk/src/SipClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,10 +779,13 @@ export class SipClient extends ServiceBase {
// Dialing a phone and waiting for an answer takes longer than a normal call,
// and the request must outlast ringing so the call can be answered. Pin the
// ring window explicitly so our request timeout doesn't depend on the server's
// default (which could change out from under us).
// default (which could change out from under us). Use locals so the
// caller's options object is left as it was passed in.
let ringingTimeout = opts.ringingTimeout;
let timeout = opts.timeout;
if (opts.waitUntilAnswered) {
opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
timeout = dialRequestTimeout(timeout, ringingTimeout);
}

const req = new CreateSIPParticipantRequest({
Expand All @@ -801,8 +804,8 @@ export class SipClient extends ServiceBase {
headers: opts.headers,
hidePhoneNumber: opts.hidePhoneNumber,
includeHeaders: opts.includeHeaders,
ringingTimeout: opts.ringingTimeout
? new Duration({ seconds: BigInt(opts.ringingTimeout) })
ringingTimeout: ringingTimeout
? new Duration({ seconds: BigInt(ringingTimeout) })
: undefined,
maxCallDuration: opts.maxCallDuration
? new Duration({ seconds: BigInt(opts.maxCallDuration) })
Expand All @@ -818,7 +821,7 @@ export class SipClient extends ServiceBase {
'CreateSIPParticipant',
req,
await this.authHeader({}, { call: true }),
opts.timeout,
timeout,
);
return SIPParticipantInfo.fromJson(data, { ignoreUnknownFields: true });
} catch (e) {
Expand Down Expand Up @@ -846,18 +849,19 @@ export class SipClient extends ServiceBase {

// Transferring a call dials a phone, which takes longer than a normal call,
// so keep the request alive past ringing. Pin the ring window explicitly so
// our request timeout doesn't depend on the server's default.
opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
// our request timeout doesn't depend on the server's default. Use locals so
// the caller's options object is left as it was passed in.
const ringingTimeout = opts.ringingTimeout ?? DEFAULT_RINGING_TIMEOUT_SECONDS;
const timeout = dialRequestTimeout(opts.timeout, ringingTimeout);

const req = new TransferSIPParticipantRequest({
participantIdentity: participantIdentity,
roomName: roomName,
transferTo: transferTo,
playDialtone: opts.playDialtone,
headers: opts.headers,
ringingTimeout: opts.ringingTimeout
? new Duration({ seconds: BigInt(opts.ringingTimeout) })
ringingTimeout: ringingTimeout
? new Duration({ seconds: BigInt(ringingTimeout) })
: undefined,
}).toJson();

Expand All @@ -867,7 +871,7 @@ export class SipClient extends ServiceBase {
'TransferSIPParticipant',
req,
await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
opts.timeout,
timeout,
);
} catch (e) {
throw asSipCallError(e);
Expand Down
Loading