/** * 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: '', * }); * ``` */ 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; } }