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';
}
}

190
ts/sip/helpers.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* SIP helper utilities — ID generation and SDP construction.
*/
import { randomBytes, createHash } from 'node:crypto';
// ---------------------------------------------------------------------------
// ID generators
// ---------------------------------------------------------------------------
/** Generate a random SIP Call-ID. */
export function generateCallId(domain?: string): string {
const id = randomBytes(16).toString('hex');
return domain ? `${id}@${domain}` : id;
}
/** Generate a random SIP From/To tag. */
export function generateTag(): string {
return randomBytes(8).toString('hex');
}
/** Generate a RFC 3261 compliant Via branch (starts with z9hG4bK magic cookie). */
export function generateBranch(): string {
return `z9hG4bK-${randomBytes(8).toString('hex')}`;
}
// ---------------------------------------------------------------------------
// Codec registry
// ---------------------------------------------------------------------------
const CODEC_NAMES: Record<number, string> = {
0: 'PCMU/8000',
3: 'GSM/8000',
4: 'G723/8000',
8: 'PCMA/8000',
9: 'G722/8000',
18: 'G729/8000',
101: 'telephone-event/8000',
};
/** Look up the rtpmap name for a static payload type. */
export function codecName(pt: number): string {
return CODEC_NAMES[pt] || `unknown/${pt}`;
}
// ---------------------------------------------------------------------------
// SDP builder
// ---------------------------------------------------------------------------
export interface ISdpOptions {
/** IP address for the c= and o= lines. */
ip: string;
/** Audio port for the m=audio line. */
port: number;
/** RTP payload type numbers (e.g. [9, 0, 8, 101]). */
payloadTypes?: number[];
/** SDP session ID (random if omitted). */
sessionId?: string;
/** Session name for the s= line (defaults to '-'). */
sessionName?: string;
/** Direction attribute (defaults to 'sendrecv'). */
direction?: 'sendrecv' | 'recvonly' | 'sendonly' | 'inactive';
/** Extra a= lines to append (without "a=" prefix). */
attributes?: string[];
}
/**
* Build a minimal SDP body suitable for SIP INVITE offers/answers.
*
* ```ts
* const sdp = buildSdp({
* ip: '192.168.5.66',
* port: 20000,
* payloadTypes: [9, 0, 101],
* });
* ```
*/
export function buildSdp(options: ISdpOptions): string {
const {
ip,
port,
payloadTypes = [9, 0, 8, 101],
sessionId = String(Math.floor(Math.random() * 1e9)),
sessionName = '-',
direction = 'sendrecv',
attributes = [],
} = options;
const lines: string[] = [
'v=0',
`o=- ${sessionId} ${sessionId} IN IP4 ${ip}`,
`s=${sessionName}`,
`c=IN IP4 ${ip}`,
't=0 0',
`m=audio ${port} RTP/AVP ${payloadTypes.join(' ')}`,
];
for (const pt of payloadTypes) {
const name = CODEC_NAMES[pt];
if (name) lines.push(`a=rtpmap:${pt} ${name}`);
if (pt === 101) lines.push(`a=fmtp:101 0-16`);
}
lines.push(`a=${direction}`);
for (const attr of attributes) lines.push(`a=${attr}`);
lines.push(''); // trailing CRLF
return lines.join('\r\n');
}
// ---------------------------------------------------------------------------
// SIP Digest authentication (RFC 2617)
// ---------------------------------------------------------------------------
export interface IDigestChallenge {
realm: string;
nonce: string;
algorithm?: string;
opaque?: string;
qop?: string;
}
/**
* Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value
* into its constituent fields.
*/
export function parseDigestChallenge(header: string): IDigestChallenge | null {
if (!header.toLowerCase().startsWith('digest ')) return null;
const params = header.slice(7);
const get = (key: string): string | undefined => {
const re = new RegExp(`${key}\\s*=\\s*"([^"]*)"`, 'i');
const m = params.match(re);
if (m) return m[1];
// unquoted value
const re2 = new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, 'i');
const m2 = params.match(re2);
return m2 ? m2[1] : undefined;
};
const realm = get('realm');
const nonce = get('nonce');
if (!realm || !nonce) return null;
return { realm, nonce, algorithm: get('algorithm'), opaque: get('opaque'), qop: get('qop') };
}
function md5(s: string): string {
return createHash('md5').update(s).digest('hex');
}
/**
* Compute a SIP Digest `Proxy-Authorization` or `Authorization` header value.
*/
export function computeDigestAuth(options: {
username: string;
password: string;
realm: string;
nonce: string;
method: string;
uri: string;
algorithm?: string;
opaque?: string;
}): string {
const ha1 = md5(`${options.username}:${options.realm}:${options.password}`);
const ha2 = md5(`${options.method}:${options.uri}`);
const response = md5(`${ha1}:${options.nonce}:${ha2}`);
let header = `Digest username="${options.username}", realm="${options.realm}", ` +
`nonce="${options.nonce}", uri="${options.uri}", response="${response}", ` +
`algorithm=${options.algorithm || 'MD5'}`;
if (options.opaque) header += `, opaque="${options.opaque}"`;
return header;
}
/**
* Parse the audio media port and connection address from an SDP body.
* Returns null when no c= + m=audio pair is found.
*/
export function parseSdpEndpoint(sdp: string): { address: string; port: number } | null {
let addr: string | null = null;
let port: number | null = null;
for (const raw of sdp.replace(/\r\n/g, '\n').split('\n')) {
const line = raw.trim();
if (line.startsWith('c=IN IP4 ')) {
addr = line.slice('c=IN IP4 '.length).trim();
} else if (line.startsWith('m=audio ')) {
const parts = line.split(' ');
if (parts.length >= 2) port = parseInt(parts[1], 10);
}
}
return addr && port ? { address: addr, port } : null;
}

16
ts/sip/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export { SipMessage } from './message.ts';
export { SipDialog } from './dialog.ts';
export type { TDialogState } from './dialog.ts';
export { rewriteSipUri, rewriteSdp } from './rewrite.ts';
export {
generateCallId,
generateTag,
generateBranch,
codecName,
buildSdp,
parseSdpEndpoint,
parseDigestChallenge,
computeDigestAuth,
} from './helpers.ts';
export type { ISdpOptions, IDigestChallenge } from './helpers.ts';
export type { IEndpoint } from './types.ts';

316
ts/sip/message.ts Normal file
View File

@@ -0,0 +1,316 @@
/**
* SipMessage — parse, inspect, mutate, and serialize SIP messages.
*
* Provides a fluent (builder-style) API so callers can chain header
* manipulations before serializing:
*
* const buf = SipMessage.parse(raw)!
* .setHeader('Contact', newContact)
* .prependHeader('Record-Route', rr)
* .updateContentLength()
* .serialize();
*/
import { Buffer } from 'node:buffer';
import { generateCallId, generateTag, generateBranch } from './helpers.ts';
const SIP_FIRST_LINE_RE = /^(?:[A-Z]+\s+\S+\s+SIP\/\d\.\d|SIP\/\d\.\d\s+\d+\s+)/;
export class SipMessage {
startLine: string;
headers: [string, string][];
body: string;
constructor(startLine: string, headers: [string, string][], body: string) {
this.startLine = startLine;
this.headers = headers;
this.body = body;
}
// -------------------------------------------------------------------------
// Parsing
// -------------------------------------------------------------------------
static parse(buf: Buffer): SipMessage | null {
if (!buf.length) return null;
if (buf[0] < 0x41 || buf[0] > 0x7a) return null;
let text: string;
try { text = buf.toString('utf8'); } catch { return null; }
let head: string;
let body: string;
let sep = text.indexOf('\r\n\r\n');
if (sep !== -1) {
head = text.slice(0, sep);
body = text.slice(sep + 4);
} else {
sep = text.indexOf('\n\n');
if (sep !== -1) {
head = text.slice(0, sep);
body = text.slice(sep + 2);
} else {
head = text;
body = '';
}
}
const lines = head.replace(/\r\n/g, '\n').split('\n');
if (!lines.length || !lines[0]) return null;
const startLine = lines[0];
if (!SIP_FIRST_LINE_RE.test(startLine)) return null;
const headers: [string, string][] = [];
for (const line of lines.slice(1)) {
if (!line.trim()) continue;
const colon = line.indexOf(':');
if (colon === -1) continue;
headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]);
}
return new SipMessage(startLine, headers, body);
}
// -------------------------------------------------------------------------
// Serialization
// -------------------------------------------------------------------------
serialize(): Buffer {
const head = [this.startLine, ...this.headers.map(([n, v]) => `${n}: ${v}`)].join('\r\n') + '\r\n\r\n';
return Buffer.concat([Buffer.from(head, 'utf8'), Buffer.from(this.body || '', 'utf8')]);
}
// -------------------------------------------------------------------------
// Inspectors
// -------------------------------------------------------------------------
get isRequest(): boolean {
return !this.startLine.startsWith('SIP/');
}
get isResponse(): boolean {
return this.startLine.startsWith('SIP/');
}
/** Request method (INVITE, REGISTER, ...) or null for responses. */
get method(): string | null {
if (!this.isRequest) return null;
return this.startLine.split(' ')[0];
}
/** Response status code or null for requests. */
get statusCode(): number | null {
if (!this.isResponse) return null;
return parseInt(this.startLine.split(' ')[1], 10);
}
get callId(): string {
return this.getHeader('Call-ID') || 'noid';
}
/** Method from the CSeq header (e.g. "INVITE"). */
get cseqMethod(): string | null {
const cseq = this.getHeader('CSeq');
if (!cseq) return null;
const parts = cseq.trim().split(/\s+/);
return parts.length >= 2 ? parts[1] : null;
}
/** True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE. */
get isDialogEstablishing(): boolean {
return /^(INVITE|SUBSCRIBE|REFER|NOTIFY|UPDATE)\s/.test(this.startLine);
}
/** True when the body carries an SDP payload. */
get hasSdpBody(): boolean {
const ct = (this.getHeader('Content-Type') || '').toLowerCase();
return !!this.body && ct.startsWith('application/sdp');
}
// -------------------------------------------------------------------------
// Header accessors (fluent)
// -------------------------------------------------------------------------
getHeader(name: string): string | null {
const nl = name.toLowerCase();
for (const [n, v] of this.headers) if (n.toLowerCase() === nl) return v;
return null;
}
/** Overwrites the first header with the given name, or appends it. */
setHeader(name: string, value: string): this {
const nl = name.toLowerCase();
for (const h of this.headers) {
if (h[0].toLowerCase() === nl) { h[1] = value; return this; }
}
this.headers.push([name, value]);
return this;
}
/** Inserts a header at the top of the header list. */
prependHeader(name: string, value: string): this {
this.headers.unshift([name, value]);
return this;
}
/** Removes all headers with the given name. */
removeHeader(name: string): this {
const nl = name.toLowerCase();
this.headers = this.headers.filter(([n]) => n.toLowerCase() !== nl);
return this;
}
/** Recalculates Content-Length to match the current body. */
updateContentLength(): this {
const len = Buffer.byteLength(this.body || '', 'utf8');
return this.setHeader('Content-Length', String(len));
}
// -------------------------------------------------------------------------
// Start-line mutation
// -------------------------------------------------------------------------
/** Replaces the Request-URI (second token) of a request start line. */
setRequestUri(uri: string): this {
if (!this.isRequest) return this;
const parts = this.startLine.split(' ');
if (parts.length >= 2) {
parts[1] = uri;
this.startLine = parts.join(' ');
}
return this;
}
/** Returns the Request-URI (second token) of a request start line. */
get requestUri(): string | null {
if (!this.isRequest) return null;
return this.startLine.split(' ')[1] || null;
}
// -------------------------------------------------------------------------
// Factory methods — build new SIP messages from scratch
// -------------------------------------------------------------------------
/**
* Build a new SIP request.
*
* ```ts
* const invite = SipMessage.createRequest('INVITE', 'sip:user@host', {
* from: { uri: 'sip:me@proxy', tag: 'abc' },
* to: { uri: 'sip:user@host' },
* via: { host: '192.168.5.66', port: 5070 },
* contact: '<sip:me@192.168.5.66:5070>',
* });
* ```
*/
static createRequest(method: string, requestUri: string, options: {
via: { host: string; port: number; transport?: string; branch?: string };
from: { uri: string; displayName?: string; tag?: string };
to: { uri: string; displayName?: string; tag?: string };
callId?: string;
cseq?: number;
contact?: string;
maxForwards?: number;
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
}): SipMessage {
const branch = options.via.branch || generateBranch();
const transport = options.via.transport || 'UDP';
const fromTag = options.from.tag || generateTag();
const callId = options.callId || generateCallId();
const cseq = options.cseq ?? 1;
const fromDisplay = options.from.displayName ? `"${options.from.displayName}" ` : '';
const toDisplay = options.to.displayName ? `"${options.to.displayName}" ` : '';
const toTag = options.to.tag ? `;tag=${options.to.tag}` : '';
const headers: [string, string][] = [
['Via', `SIP/2.0/${transport} ${options.via.host}:${options.via.port};branch=${branch};rport`],
['From', `${fromDisplay}<${options.from.uri}>;tag=${fromTag}`],
['To', `${toDisplay}<${options.to.uri}>${toTag}`],
['Call-ID', callId],
['CSeq', `${cseq} ${method}`],
['Max-Forwards', String(options.maxForwards ?? 70)],
];
if (options.contact) {
headers.push(['Contact', options.contact]);
}
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'))]);
return new SipMessage(`${method} ${requestUri} SIP/2.0`, headers, body);
}
/**
* Build a SIP response to an incoming request.
*
* Copies Via, From, To, Call-ID, and CSeq from the original request.
*/
static createResponse(
statusCode: number,
reasonPhrase: string,
request: SipMessage,
options?: {
toTag?: string;
contact?: string;
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
},
): SipMessage {
const headers: [string, string][] = [];
// Copy all Via headers (order matters).
for (const [n, v] of request.headers) {
if (n.toLowerCase() === 'via') headers.push(['Via', v]);
}
// From — copied verbatim.
const from = request.getHeader('From');
if (from) headers.push(['From', from]);
// To — add tag if provided and not already present.
let to = request.getHeader('To') || '';
if (options?.toTag && !to.includes('tag=')) {
to += `;tag=${options.toTag}`;
}
headers.push(['To', to]);
headers.push(['Call-ID', request.callId]);
const cseq = request.getHeader('CSeq');
if (cseq) headers.push(['CSeq', cseq]);
if (options?.contact) headers.push(['Contact', options.contact]);
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'))]);
return new SipMessage(`SIP/2.0 ${statusCode} ${reasonPhrase}`, headers, body);
}
/** Extract the tag from a From or To header value. */
static extractTag(headerValue: string): string | null {
const m = headerValue.match(/;tag=([^\s;>]+)/);
return m ? m[1] : null;
}
/** Extract the URI from an addr-spec or name-addr (From/To/Contact). */
static extractUri(headerValue: string): string | null {
const m = headerValue.match(/<([^>]+)>/);
return m ? m[1] : headerValue.trim().split(/[;>]/)[0] || null;
}
}

228
ts/sip/readme.md Normal file
View File

@@ -0,0 +1,228 @@
# ts/sip — SIP Protocol Library
A zero-dependency SIP (Session Initiation Protocol) library for Deno / Node.
Provides parsing, construction, mutation, and dialog management for SIP
messages, plus helpers for SDP bodies and URI rewriting.
## Modules
| File | Purpose |
|------|---------|
| `message.ts` | `SipMessage` class — parse, inspect, mutate, serialize |
| `dialog.ts` | `SipDialog` class — track dialog state, build in-dialog requests |
| `helpers.ts` | ID generators, codec registry, SDP builder/parser |
| `rewrite.ts` | SIP URI and SDP body rewriting |
| `types.ts` | Shared types (`IEndpoint`) |
| `index.ts` | Barrel re-export |
## Quick Start
```ts
import {
SipMessage,
SipDialog,
buildSdp,
parseSdpEndpoint,
rewriteSipUri,
rewriteSdp,
generateCallId,
generateTag,
generateBranch,
} from './sip/index.ts';
```
## SipMessage
### Parsing
```ts
import { Buffer } from 'node:buffer';
const raw = Buffer.from(
'INVITE sip:user@example.com SIP/2.0\r\n' +
'Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK776\r\n' +
'From: <sip:alice@example.com>;tag=abc\r\n' +
'To: <sip:bob@example.com>\r\n' +
'Call-ID: a84b4c76e66710@10.0.0.1\r\n' +
'CSeq: 1 INVITE\r\n' +
'Content-Length: 0\r\n\r\n'
);
const msg = SipMessage.parse(raw);
// msg.method → "INVITE"
// msg.isRequest → true
// msg.callId → "a84b4c76e66710@10.0.0.1"
// msg.cseqMethod → "INVITE"
// msg.isDialogEstablishing → true
```
### Fluent mutation
All setter methods return `this` for chaining:
```ts
const buf = SipMessage.parse(raw)!
.setHeader('Contact', '<sip:proxy@192.168.1.1:5070>')
.prependHeader('Record-Route', '<sip:192.168.1.1:5070;lr>')
.updateContentLength()
.serialize();
```
### Building requests from scratch
```ts
const invite = SipMessage.createRequest('INVITE', 'sip:+4930123@voip.example.com', {
via: { host: '192.168.5.66', port: 5070 },
from: { uri: 'sip:alice@example.com', displayName: 'Alice' },
to: { uri: 'sip:+4930123@voip.example.com' },
contact: '<sip:192.168.5.66:5070>',
body: sdpBody,
contentType: 'application/sdp',
});
// Call-ID, From tag, Via branch are auto-generated if not provided.
```
### Building responses
```ts
const ok = SipMessage.createResponse(200, 'OK', incomingInvite, {
toTag: generateTag(),
contact: '<sip:192.168.5.66:5070>',
body: answerSdp,
contentType: 'application/sdp',
});
```
### Inspectors
| Property | Type | Description |
|----------|------|-------------|
| `isRequest` | `boolean` | True for requests (INVITE, BYE, ...) |
| `isResponse` | `boolean` | True for responses (SIP/2.0 200 OK, ...) |
| `method` | `string \| null` | Request method or null |
| `statusCode` | `number \| null` | Response status code or null |
| `callId` | `string` | Call-ID header value |
| `cseqMethod` | `string \| null` | Method from CSeq header |
| `requestUri` | `string \| null` | Request-URI (second token of start line) |
| `isDialogEstablishing` | `boolean` | INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE |
| `hasSdpBody` | `boolean` | Body present with Content-Type: application/sdp |
### Static helpers
```ts
SipMessage.extractTag('<sip:alice@x.com>;tag=abc') // → "abc"
SipMessage.extractUri('"Alice" <sip:alice@x.com>') // → "sip:alice@x.com"
```
## SipDialog
Tracks dialog state per RFC 3261 §12. A dialog is created from a
dialog-establishing request and updated as responses arrive.
### UAC (caller) side
```ts
// 1. Build and send INVITE
const invite = SipMessage.createRequest('INVITE', destUri, { ... });
const dialog = SipDialog.fromUacInvite(invite, '192.168.5.66', 5070);
// 2. Process responses as they arrive
dialog.processResponse(trying100); // state stays 'early'
dialog.processResponse(ringing180); // state stays 'early', remoteTag learned
dialog.processResponse(ok200); // state → 'confirmed'
// 3. ACK the 200
const ack = dialog.createAck();
// 4. In-dialog requests
const bye = dialog.createRequest('BYE');
dialog.terminate();
```
### UAS (callee) side
```ts
const dialog = SipDialog.fromUasInvite(incomingInvite, generateTag(), localHost, localPort);
```
### CANCEL (before answer)
```ts
const cancel = dialog.createCancel(originalInvite);
```
### Dialog states
`'early'``'confirmed'``'terminated'`
## Helpers
### ID generation
```ts
generateCallId() // → "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
generateCallId('example.com') // → "a3f8b2c1...@example.com"
generateTag() // → "1a2b3c4d5e6f7a8b"
generateBranch() // → "z9hG4bK-1a2b3c4d5e6f7a8b"
```
### SDP builder
```ts
const sdp = buildSdp({
ip: '192.168.5.66',
port: 20000,
payloadTypes: [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
direction: 'sendrecv',
});
```
### SDP parser
```ts
const ep = parseSdpEndpoint(sdpBody);
// → { address: '10.0.0.1', port: 20000 } or null
```
### Codec names
```ts
codecName(9) // → "G722/8000"
codecName(0) // → "PCMU/8000"
codecName(101) // → "telephone-event/8000"
```
## Rewriting
### SIP URI
Replaces the host:port in all `sip:` / `sips:` URIs found in a header value:
```ts
rewriteSipUri('<sip:user@10.0.0.1:5060>', '203.0.113.1', 5070)
// → '<sip:user@203.0.113.1:5070>'
```
### SDP body
Rewrites the connection address and audio media port, returning the original
endpoint that was replaced:
```ts
const { body, original } = rewriteSdp(sdpBody, '203.0.113.1', 20000);
// original → { address: '10.0.0.1', port: 8000 }
```
## Architecture Notes
This library is intentionally low-level — it operates on individual messages
and dialogs rather than providing a full SIP stack with transport and
transaction layers. This makes it suitable for building:
- **SIP proxies** — parse, rewrite headers/SDP, serialize, forward
- **B2BUA (back-to-back user agents)** — manage two dialogs, bridge media
- **SIP testing tools** — craft and send arbitrary messages
- **Protocol analyzers** — parse and inspect SIP traffic
The library does not manage sockets, timers, or retransmissions — those
concerns belong to the application layer.

54
ts/sip/rewrite.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* SIP URI and SDP body rewriting helpers.
*/
import type { IEndpoint } from './types.ts';
const SIP_URI_RE = /(sips?:)([^@>;,\s]+@)?([^>;,\s:]+)(:\d+)?/g;
/**
* Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
*/
export function rewriteSipUri(value: string, host: string, port: number): string {
return value.replace(SIP_URI_RE, (_m, scheme: string, userpart?: string) =>
`${scheme}${userpart || ''}${host}:${port}`);
}
/**
* Rewrites the connection address (`c=`) and audio media port (`m=audio`)
* in an SDP body. Returns the rewritten body together with the original
* endpoint that was replaced (if any).
*/
export function rewriteSdp(
body: string,
ip: string,
port: number,
): { body: string; original: IEndpoint | null } {
let origAddr: string | null = null;
let origPort: number | null = null;
const out = body
.replace(/\r\n/g, '\n')
.split('\n')
.map((line) => {
if (line.startsWith('c=IN IP4 ')) {
origAddr = line.slice('c=IN IP4 '.length).trim();
return `c=IN IP4 ${ip}`;
}
if (line.startsWith('m=audio ')) {
const parts = line.split(' ');
if (parts.length >= 2) {
origPort = parseInt(parts[1], 10);
parts[1] = String(port);
}
return parts.join(' ');
}
return line;
})
.join('\r\n');
return {
body: out,
original: origAddr && origPort ? { address: origAddr, port: origPort } : null,
};
}

8
ts/sip/types.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Shared SIP types.
*/
export interface IEndpoint {
address: string;
port: number;
}