Files
siprouter/ts/sip/dialog.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

281 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SipDialog — tracks the state of a SIP dialog (RFC 3261 §12).
*
* A dialog is created by a dialog-establishing request (INVITE, SUBSCRIBE, …)
* and its 1xx/2xx response. It manages local/remote tags, CSeq counters,
* the route set, and provides helpers to build in-dialog requests (ACK, BYE,
* re-INVITE, …).
*
* Usage:
* ```ts
* // Caller (UAC) side — create from the outgoing INVITE we just sent:
* const dialog = SipDialog.fromUacInvite(invite);
*
* // When a 200 OK arrives:
* dialog.processResponse(response200);
*
* // Build ACK for the 2xx:
* const ack = dialog.createAck();
*
* // Later — hang up:
* const bye = dialog.createRequest('BYE');
* ```
*/
import { SipMessage } from './message.ts';
import { generateTag, generateBranch } from './helpers.ts';
import { Buffer } from 'node:buffer';
export type TDialogState = 'early' | 'confirmed' | 'terminated';
export class SipDialog {
callId: string;
localTag: string;
remoteTag: string | null = null;
localUri: string;
remoteUri: string;
localCSeq: number;
remoteCSeq: number = 0;
routeSet: string[] = [];
remoteTarget: string; // Contact URI of the remote party
state: TDialogState = 'early';
// Transport info for sending in-dialog messages.
localHost: string;
localPort: number;
constructor(options: {
callId: string;
localTag: string;
remoteTag?: string;
localUri: string;
remoteUri: string;
localCSeq: number;
remoteTarget: string;
localHost: string;
localPort: number;
routeSet?: string[];
}) {
this.callId = options.callId;
this.localTag = options.localTag;
this.remoteTag = options.remoteTag ?? null;
this.localUri = options.localUri;
this.remoteUri = options.remoteUri;
this.localCSeq = options.localCSeq;
this.remoteTarget = options.remoteTarget;
this.localHost = options.localHost;
this.localPort = options.localPort;
this.routeSet = options.routeSet ?? [];
}
// -------------------------------------------------------------------------
// Factory: create dialog from an outgoing INVITE (UAC side)
// -------------------------------------------------------------------------
/**
* Create a dialog from an INVITE we are sending.
* The dialog enters "early" state; call `processResponse()` when
* provisional or final responses arrive.
*/
static fromUacInvite(invite: SipMessage, localHost: string, localPort: number): SipDialog {
const from = invite.getHeader('From') || '';
const to = invite.getHeader('To') || '';
return new SipDialog({
callId: invite.callId,
localTag: SipMessage.extractTag(from) || generateTag(),
localUri: SipMessage.extractUri(from) || '',
remoteUri: SipMessage.extractUri(to) || '',
localCSeq: parseInt((invite.getHeader('CSeq') || '1').split(/\s+/)[0], 10),
remoteTarget: invite.requestUri || SipMessage.extractUri(to) || '',
localHost: localHost,
localPort: localPort,
});
}
// -------------------------------------------------------------------------
// Factory: create dialog from an incoming INVITE (UAS side)
// -------------------------------------------------------------------------
/**
* Create a dialog from an INVITE we received.
* Typically used when acting as a UAS (e.g. for call-back scenarios).
*/
static fromUasInvite(invite: SipMessage, localTag: string, localHost: string, localPort: number): SipDialog {
const from = invite.getHeader('From') || '';
const to = invite.getHeader('To') || '';
const contact = invite.getHeader('Contact');
return new SipDialog({
callId: invite.callId,
localTag,
remoteTag: SipMessage.extractTag(from) || undefined,
localUri: SipMessage.extractUri(to) || '',
remoteUri: SipMessage.extractUri(from) || '',
localCSeq: 0,
remoteTarget: (contact ? SipMessage.extractUri(contact) : null) || SipMessage.extractUri(from) || '',
localHost,
localPort,
});
}
// -------------------------------------------------------------------------
// Response processing
// -------------------------------------------------------------------------
/**
* Update dialog state from a received response.
* - 1xx with To-tag → early dialog
* - 2xx → confirmed dialog
* - 3xx6xx → terminated
*/
processResponse(response: SipMessage): void {
const to = response.getHeader('To') || '';
const tag = SipMessage.extractTag(to);
const code = response.statusCode ?? 0;
// Always update remoteTag from 2xx (RFC 3261 §12.1.2: tag in 2xx is definitive).
if (tag && (code >= 200 && code < 300)) {
this.remoteTag = tag;
} else if (tag && !this.remoteTag) {
this.remoteTag = tag;
}
// Update remote target from Contact.
const contact = response.getHeader('Contact');
if (contact) {
const uri = SipMessage.extractUri(contact);
if (uri) this.remoteTarget = uri;
}
// Record-Route → route set (in reverse for UAC).
if (this.state === 'early') {
const rr: string[] = [];
for (const [n, v] of response.headers) {
if (n.toLowerCase() === 'record-route') rr.push(v);
}
if (rr.length) this.routeSet = rr.reverse();
}
if (code >= 200 && code < 300) {
this.state = 'confirmed';
} else if (code >= 300) {
this.state = 'terminated';
}
}
// -------------------------------------------------------------------------
// Request building
// -------------------------------------------------------------------------
/**
* Build an in-dialog request (BYE, re-INVITE, INFO, …).
* Automatically increments the local CSeq.
*/
createRequest(method: string, options?: {
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
}): SipMessage {
this.localCSeq++;
const branch = generateBranch();
const headers: [string, string][] = [
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
['From', `<${this.localUri}>;tag=${this.localTag}`],
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} ${method}`],
['Max-Forwards', '70'],
];
// Route set → Route headers.
for (const route of this.routeSet) {
headers.push(['Route', route]);
}
headers.push(['Contact', `<sip:${this.localHost}:${this.localPort}>`]);
if (options?.extraHeaders) headers.push(...options.extraHeaders);
const body = options?.body || '';
if (body && options?.contentType) {
headers.push(['Content-Type', options.contentType]);
}
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
// Determine Request-URI from route set or remote target.
let ruri = this.remoteTarget;
if (this.routeSet.length) {
const topRoute = SipMessage.extractUri(this.routeSet[0]);
if (topRoute && topRoute.includes(';lr')) {
ruri = this.remoteTarget; // loose routing — RURI stays as remote target
} else if (topRoute) {
ruri = topRoute; // strict routing — top route becomes RURI
}
}
return new SipMessage(`${method} ${ruri} SIP/2.0`, headers, body);
}
/**
* Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
* ACK for 2xx is a new transaction, so it gets its own Via/branch.
*/
createAck(): SipMessage {
const branch = generateBranch();
const headers: [string, string][] = [
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
['From', `<${this.localUri}>;tag=${this.localTag}`],
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} ACK`],
['Max-Forwards', '70'],
];
for (const route of this.routeSet) {
headers.push(['Route', route]);
}
headers.push(['Content-Length', '0']);
let ruri = this.remoteTarget;
if (this.routeSet.length) {
const topRoute = SipMessage.extractUri(this.routeSet[0]);
if (topRoute && topRoute.includes(';lr')) {
ruri = this.remoteTarget;
} else if (topRoute) {
ruri = topRoute;
}
}
return new SipMessage(`ACK ${ruri} SIP/2.0`, headers, '');
}
/**
* Build a CANCEL for the original INVITE (same branch, CSeq).
* Used before the dialog is confirmed.
*/
createCancel(originalInvite: SipMessage): SipMessage {
const via = originalInvite.getHeader('Via') || '';
const from = originalInvite.getHeader('From') || '';
const to = originalInvite.getHeader('To') || '';
const headers: [string, string][] = [
['Via', via],
['From', from],
['To', to],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} CANCEL`],
['Max-Forwards', '70'],
['Content-Length', '0'],
];
const ruri = originalInvite.requestUri || this.remoteTarget;
return new SipMessage(`CANCEL ${ruri} SIP/2.0`, headers, '');
}
/** Transition the dialog to terminated state. */
terminate(): void {
this.state = 'terminated';
}
}