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
This commit is contained in:
iamdoubz committed 2026-10-18 09:27:45 -05:00
1 parent f60dda0bb0
commit ae3a723a3c
20 files changed
+703 -4

No files matched your search

+1
View File
@@ -17,6 +17,7 @@
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
"react-router": "^8.4.0",
"sip.js": "0.21.2",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
+2
View File
@@ -25,6 +25,7 @@ import type {
UserDto,
UserUpdate,
VoicemailDto,
WebRtcCreds,
Warning,
} from '@selfphoneng/shared';
@@ -145,6 +146,7 @@ export const systemApi = {
};
export const meApi = {
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
audioUrl: (id: string, download = false) =>
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
+2
View File
@@ -9,6 +9,7 @@ import { authApi, meApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -62,6 +63,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route index element={<Home user={user} />} />
<Route path="voicemail" element={<VoicemailPage />} />
</Routes>
<SoftphoneDock />
</main>
);
}
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Softphone bar on every /me page (05 §5.3). M5-T1: enable, status, dial, answer, hang up.
// M5-T2 adds the expanding panel, hold/mute/DTMF, transfer, devices and notifications.
import { useEffect, useState, useSyncExternalStore } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { Button, inputClass } from '../components/ui.tsx';
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
const KEY = 'softphone.enabled';
const remembered = () => {
try {
return localStorage.getItem(KEY) === '1';
} catch {
return false;
}
};
const remember = (on: boolean) => {
try {
if (on) localStorage.setItem(KEY, '1');
else localStorage.removeItem(KEY);
} catch {
/* private mode: just don't remember */
}
};
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
const STATUS = { off: 'Off', connecting: 'Connecting…', registered: 'Ready', error: 'Error' };
async function startSoftphone() {
const creds = await meApi.webrtc();
await softphone.start(creds);
}
export function SoftphoneDock() {
const { reg, error, calls } = useSoftphone();
const [number, setNumber] = useState('');
const [problem, setProblem] = useState<string | null>(null);
useEffect(() => {
if (remembered() && softphone.state.reg === 'off')
startSoftphone().catch((e: Error) => setProblem(e.message));
}, []);
async function enable() {
setProblem(null);
try {
// Ask for the microphone now, on a click, not in the middle of the first call.
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
mic.getTracks().forEach((t) => t.stop());
await startSoftphone();
remember(true);
} catch (e) {
setProblem(
(e as Error).name === 'NotAllowedError'
? 'Microphone access was blocked.'
: (e as Error).message,
);
}
}
async function dial() {
const n = number.replace(/[^0-9*#+]/g, '');
if (!n) return;
setProblem(null);
await softphone.call(n).catch((e: Error) => setProblem(e.message));
}
return (
<section
aria-label="Softphone"
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-zinc-200 bg-white py-3 dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
Softphone: <span role="status">{STATUS[reg]}</span>
</span>
{reg === 'off' ? (
<Button variant="primary" onClick={enable}>
Enable softphone
</Button>
) : (
<Button
variant="ghost"
onClick={() => {
remember(false);
void softphone.stop();
}}
>
Turn off
</Button>
)}
</div>
{(problem ?? error) && (
<p className="text-sm text-red-600 dark:text-red-400">{problem ?? error}</p>
)}
{reg === 'registered' && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void dial();
}}
>
<label htmlFor="sp-number" className="sr-only">
Number to call
</label>
<input
id="sp-number"
className={inputClass}
inputMode="tel"
autoComplete="off"
placeholder="Number or extension"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<Button type="submit" variant="primary">
Call
</Button>
</form>
)}
{calls.map((c) => (
<CallRow key={c.id} call={c} />
))}
</section>
);
}
function CallRow({ call }: { call: CallInfo }) {
const who = call.remoteName ?? (call.remote.length > 6 ? formatNumber(call.remote) : call.remote);
const label = { incoming: 'Incoming call', calling: 'Calling…', active: 'On call' }[call.state];
return (
<div
role="group"
aria-label={`${label}: ${who}`}
className="flex flex-wrap items-center gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
>
<span className="flex-1 text-sm">
<span className="font-medium">{who}</span> · {label}
</span>
{call.state === 'incoming' && (
<>
<Button variant="primary" onClick={() => void softphone.answer(call.id)}>
Answer
</Button>
<Button variant="ghost" onClick={() => void softphone.decline(call.id)}>
Decline
</Button>
</>
)}
{call.state !== 'incoming' && (
<Button variant="danger" onClick={() => void softphone.hangup(call.id)}>
Hang up
</Button>
)}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
// 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();