Skip to main content

Build a WebRTC call with IOSignal

IOSignal carries discovery, invitations, SDP, and ICE messages. WebRTC carries media. A ready signaling connection alone does not prove that a call works.

Use the runnable WebRTC example or the 7.2.0 example bundle. The app is a two-party development example; room capacity, call ownership, and permissions are application rules.

Routing with 7.2.0​

import { IO } from 'iosignal/io';
const io = new IO();
const channel = 'webrtc-lab-learning';
io.on('message', (tag, payload) => {
if (tag !== channel || payload?.type !== 'hello' ||
typeof payload.from !== 'string' || payload.from === io.cid) return;
io.signal(payload.from + '@rtc', { type: 'welcome', from: io.cid });
});
io.on('@rtc', (tag, payload) => console.log(tag, payload));
io.on('ready', () => {
io.subscribe(channel);
io.signal(channel, { type: 'hello', from: io.cid });
});
io.on('error', error => console.error(error));
io.open('ws://localhost:7781');
window.addEventListener('pagehide', () => io.destroy(), { once: true });

Use an ordinary shared channel for peers on different networks. A #home channel groups clients by the server-observed network. Direct @rtc reception needs a listener, not subscribe('@rtc'). A direct message emits common @ and exact @rtc; process it in one handler to avoid duplicate work. It does not reach message.

Application protocol​

Discovery uses channel messages host and knock. Direct messages include invite, accept, busy, offer, answer, ice, and bye. Send descriptions as plain serializable data and candidates via candidate.toJSON().

Validate the envelope's v, room, from, type, and session. Here v is the application's protocol version, not IOSignal's package version. Discovery can use a null session; the host creates a fresh ID for the call. A payload's from field is not authenticated sender identity.

Reject invalid types/sizes, messages from another room/peer/session, stale-call messages, and duplicate accepts. Define who owns the offer; the example fixes this role to the host. Serialize asynchronous signaling steps, queue ICE candidates until the remote description is set, and check that the session is still active after every asynchronous operation. If media permission resolves after cancellation, stop those late tracks.

Connection and lifecycle​

The example bounds initial ready waiting at 15 seconds, discovery/media waiting at 45 seconds, and retries discovery every 2.5 seconds. Public IOSignal servers may redirect before the first ready, closing the previous socket. Do not destroy the client on every pre-ready close. After readiness, signaling disconnection ends the call. WebRTC disconnected receives a 15-second grace period; failed ends it.

On finish, send bye if possible, deactivate the session, clear discovery/statistics/connection timers, close the peer connection, destroy the IO client, stop all local tracks, clear video srcObject, and discard queued ICE candidates.

Microphone/camera toggles change track enabled; stop() releases devices on finish. Mute local preview. Remote volume belongs to the remote video element. Provide a manual play action when autoplay fails; test mobile volume/fullscreen behavior separately.

Different networks and deployment​

Serve the page over HTTPS and signaling over reachable WSS. A remote user's localhost refers to their own device. Configure a valid TURN service when direct connectivity is unavailable. Do not put TURN credentials in code, invite links, or logs; this demo accepts settings separately in each tab, while invitations carry only server and channel.

To verify TURN, force iceTransportPolicy: 'relay', inspect the selected candidate pair for relay candidates, and verify media actually arrives. Configuring a TURN URL is not a relay test. Room links, QR codes, and busy are not authenticated admission controls. A production application needs its own participation permissions and expiring credential issuance.

Run and inspect​

cd iosignal_webrtc
npm ci
npm run dev

Open http://localhost:5180 in two tabs. The local IOSignal server uses port 7781. Inspect src/main.js (begin, send, receive, createPeer, finish), vite.config.js, server.js, and tests/call.spec.js.

Use npm run build for production assets. Build/preview do not start signaling; run npm run server separately. For tests, install Chromium with npx playwright install chromium and run npm test, or set CHROME_PATH to an installed Chrome executable.

Verified on 2026-10-18​

The 7.2.0 build and five local browser integration tests passed: two-peer media/control and third-peer busy handling; validation/invites/responsive UI; guest-before-host joining; camera-denial recovery; and compact custom-server invite/QR round trips. Cleanup and rejoining are included.

Tests used two Chrome contexts on one computer with simulated capture devices and a local IOSignal server. They do not establish real microphone audibility, physical camera behavior, Safari/iOS support, cross-network connectivity, public deployment health, or TURN relay operation. For new implementations, inspect peer state, remote frames/audio tracks, user controls, and released devices—not only signaling readiness.