Files
selfphoneng/apps/web/src/softphone/SoftphoneEngine.ts
T
iamdoubz ae3a723a3c softphone: SIP.js engine, /api/me/webrtc, minimal dock
GET /api/me/webrtc returns the <ext>-web identity, WebSocket URL
(WEBRTC_WS_URL overrides wss://PUBLIC_HOSTNAME/ws) and STUN/TURN servers.
SoftphoneEngine wraps SIP.js 0.21.2: register with backoff reconnect,
call, answer, decline, hang up, remote audio. A minimal dock on /me
enables it (asks for the microphone on the click) and dials/answers;
M5-T2 grows it into the full panel.

Generator: ice_host_candidates keeps the LAN address
(include_local_address), so LAN browsers don't need hairpin NAT, and -web
endpoints set from_domain = selfphoneng.invalid because SIP.js drops
INVITEs whose From host starts with a digit (Docker/VPS hostnames).

e2e m5-t1 (Docker): browser registers as 170-web and has two-way audio
with a baresip phone both ways. The LAN and hotspot check on the owner's
server is still open (roadmap note).

Task: M5-T1
2026-10-18 09:27:45 -05:00

222 lines
7.0 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// The browser softphone (05 §5.2): SIP.js over WebSocket to Asterisk, no React in here.
// React reads it through subscribe()/state (useSyncExternalStore in useSoftphone.ts).
import {
Invitation,
Inviter,
Registerer,
RegistererState,
SessionState,
UserAgent,
Web,
type Session,
} from 'sip.js';
import type { WebRtcCreds } from '@selfphoneng/shared';
export type RegState = 'off' | 'connecting' | 'registered' | 'error';
export type CallInfo = {
id: string;
direction: 'in' | 'out';
/** The other side's number (SIP user part). */
remote: string;
remoteName: string | null;
/** incoming: ringing here; calling: ringing there; active: talking. */
state: 'incoming' | 'calling' | 'active';
answeredAt: number | null;
};
export type EngineState = { reg: RegState; error: string | null; calls: CallInfo[] };
const BACKOFF_S = [2, 4, 8, 16, 30];
const MAX_CALLS = 2;
type Tracked = { session: Session; info: CallInfo; audio: HTMLAudioElement };
export class SoftphoneEngine {
state: EngineState = { reg: 'off', error: null, calls: [] };
private listeners = new Set<() => void>();
private ua: UserAgent | null = null;
private registerer: Registerer | null = null;
private host = '';
private sessions = new Map<string, Tracked>();
private retry: ReturnType<typeof setTimeout> | undefined;
private attempt = 0;
private stopped = true;
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => void this.listeners.delete(fn);
};
private set(patch: Partial<EngineState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
private publishCalls() {
this.set({ calls: [...this.sessions.values()].map((t) => ({ ...t.info })) });
}
async start(creds: WebRtcCreds): Promise<void> {
await this.stop();
this.stopped = false;
this.host = new URL(creds.sipUri.replace(/^sip:/, 'sip://')).hostname;
this.set({ reg: 'connecting', error: null });
const ua = new UserAgent({
uri: UserAgent.makeURI(creds.sipUri),
transportOptions: { server: creds.wsUrl },
authorizationUsername: creds.authUser,
authorizationPassword: creds.password,
displayName: creds.displayName,
contactParams: { transport: creds.wsUrl.startsWith('wss:') ? 'wss' : 'ws' },
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: { iceServers: creds.iceServers },
},
logLevel: 'warn',
delegate: {
onInvite: (inv) => this.incoming(inv),
onDisconnect: (err) => {
if (err && !this.stopped) this.reconnect();
},
},
});
this.ua = ua;
const registerer = new Registerer(ua, { expires: 300 });
this.registerer = registerer;
registerer.stateChange.addListener((s) => {
if (this.stopped) return;
if (s === RegistererState.Registered) {
this.attempt = 0;
this.set({ reg: 'registered', error: null });
} else if (this.state.reg === 'registered') this.set({ reg: 'connecting' });
});
try {
await ua.start();
await this.register();
} catch (e) {
this.set({ reg: 'error', error: (e as Error).message });
this.reconnect();
}
}
private register() {
return this.registerer!.register({
requestDelegate: {
onReject: (res) =>
this.set({
reg: 'error',
error: `Registration refused (${res.message.statusCode} ${res.message.reasonPhrase})`,
}),
},
});
}
/** Transport lost: reconnect with backoff 2 → 30 s, then register again. */
private reconnect() {
clearTimeout(this.retry);
if (this.stopped || !this.ua) return;
if (this.state.reg !== 'error') this.set({ reg: 'connecting' });
const s = BACKOFF_S[Math.min(this.attempt++, BACKOFF_S.length - 1)]!;
this.retry = setTimeout(() => {
if (this.stopped || !this.ua) return;
this.ua
.reconnect()
.then(() => this.register())
.catch(() => this.reconnect());
}, s * 1000);
}
async stop(): Promise<void> {
this.stopped = true;
clearTimeout(this.retry);
for (const id of [...this.sessions.keys()]) await this.hangup(id).catch(() => undefined);
await this.registerer?.unregister().catch(() => undefined);
await this.ua?.stop().catch(() => undefined);
this.ua = null;
this.registerer = null;
this.set({ reg: 'off', error: null, calls: [] });
}
async call(number: string): Promise<void> {
const target = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
if (!this.ua || !target) throw new Error('Softphone is not started');
if (this.sessions.size >= MAX_CALLS) throw new Error('Two calls already');
const inviter = new Inviter(this.ua, target, {
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
});
this.track(inviter, 'out', number, null);
await inviter.invite();
}
private incoming(inv: Invitation) {
if (this.sessions.size >= MAX_CALLS) {
void inv.reject({ statusCode: 486 });
return;
}
this.track(
inv,
'in',
inv.remoteIdentity.uri.user ?? '',
inv.remoteIdentity.displayName || null,
);
}
async answer(id: string): Promise<void> {
const t = this.sessions.get(id);
if (!(t?.session instanceof Invitation)) return;
await t.session.accept({
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
});
}
async decline(id: string): Promise<void> {
const t = this.sessions.get(id);
if (t?.session instanceof Invitation) await t.session.reject({ statusCode: 486 });
}
/** bye() once talking; before that, cancel (outgoing) or reject (incoming). */
async hangup(id: string): Promise<void> {
const s = this.sessions.get(id)?.session;
if (!s) return;
if (s.state === SessionState.Established) await s.bye();
else if (s instanceof Inviter) await s.cancel();
else if (s instanceof Invitation) await s.reject();
}
private track(session: Session, direction: 'in' | 'out', remote: string, name: string | null) {
const id = session.id;
const audio = new Audio();
audio.autoplay = true;
const info: CallInfo = {
id,
direction,
remote,
remoteName: name,
state: direction === 'in' ? 'incoming' : 'calling',
answeredAt: null,
};
this.sessions.set(id, { session, info, audio });
session.stateChange.addListener((s) => {
if (s === SessionState.Established) {
const sdh = session.sessionDescriptionHandler;
if (sdh instanceof Web.SessionDescriptionHandler) {
audio.srcObject = sdh.remoteMediaStream;
void audio.play().catch(() => undefined);
}
info.state = 'active';
info.answeredAt = Date.now();
}
if (s === SessionState.Terminated) {
audio.srcObject = null;
this.sessions.delete(id);
}
this.publishCalls();
});
this.publishCalls();
}
}
/** One engine per tab. */
export const softphone = new SoftphoneEngine();