Files
siprouter/ts/sip/message.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

317 lines
10 KiB
TypeScript

/**
* 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;
}
}