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.
This commit is contained in:
2026-04-09 23:03:55 +00:00
commit f3e1c96872
59 changed files with 18377 additions and 0 deletions

280
ts/sip/dialog.ts Normal file
View File

@@ -0,0 +1,280 @@
/**
* 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';
}
}