Docs

Xirsys Quick Start

Updated

On this page

Build a minimal two-browser WebRTC call with Xirsys TURN and Signaling V2. One tab is the caller and one is the answerer, which prevents offer glare and makes the negotiation flow easy to inspect.

This example requires Node.js 18 or later, an existing Xirsys channel, and two browser tabs or devices with camera access. The backend holds the Xirsys secret; the browser receives only temporary ICE credentials and signaling configuration.

1. Create the Project

TerminalShell
mkdir xirsys-quick-start
cd xirsys-quick-start
npm init -y
npm install express
mkdir public

Set these environment variables in your shell or deployment secret manager:

Environment (server only)Shell
export XIRSYS_IDENT="your-account-ident"
export XIRSYS_SECRET="your-api-secret"
export XIRSYS_CHANNEL="your-existing-channel"

Channel names are case-sensitive complete identifiers. Do not use forward slashes to build a hierarchy. Keep the secret out of source control and never send it to the browser.

2. Add server.js

server.jsJS
const express = require('express');

const app = express();
const port = process.env.PORT || 3000;
const baseUrl = 'https://global.xirsys.net';
const { XIRSYS_IDENT, XIRSYS_SECRET, XIRSYS_CHANNEL } = process.env;

if (!XIRSYS_IDENT || !XIRSYS_SECRET || !XIRSYS_CHANNEL) {
  throw new Error('Set XIRSYS_IDENT, XIRSYS_SECRET, and XIRSYS_CHANNEL');
}

const auth = Buffer.from(
  `${XIRSYS_IDENT}:${XIRSYS_SECRET}`
).toString('base64');
const headers = { Authorization: `Basic ${auth}` };
const encodedChannel = encodeURIComponent(XIRSYS_CHANNEL);

async function readXirsys(response, operation) {
  const data = await response.json();
  if (!response.ok || data.s !== 'ok') {
    throw new Error(`${operation} failed (${response.status})`);
  }
  return data.v;
}

app.use(express.static('public'));

app.get('/api/ice', async (_request, response) => {
  try {
    const upstream = await fetch(
      `${baseUrl}/_turn/${encodedChannel}?webrtc=1&expire=60`,
      {
        method: 'PUT',
        headers
      }
    );
    const value = await readXirsys(upstream, 'TURN request');
    response.json({ iceServers: value.iceServers });
  } catch (error) {
    console.error(error.message);
    response.status(502).json({ error: 'Unable to obtain ICE servers' });
  }
});

app.get('/api/signal-config', async (request, response) => {
  const peerId = String(request.query.peerId || '');
  if (!/^[A-Za-z0-9-]{1,64}$/.test(peerId)) {
    return response.status(400).json({ error: 'Invalid peerId' });
  }

  try {
    const [hostResponse, tokenResponse] = await Promise.all([
      fetch(`${baseUrl}/_host?type=signal`, { headers }),
      fetch(
        `${baseUrl}/_token/${encodedChannel}` +
          `?k=${encodeURIComponent(peerId)}&expire=120`,
        { method: 'PUT', headers }
      )
    ]);

    const [host, token] = await Promise.all([
      readXirsys(hostResponse, 'Host request'),
      readXirsys(tokenResponse, 'Token request')
    ]);

    response.json({ host, token });
  } catch (error) {
    console.error(error.message);
    response.status(502).json({ error: 'Unable to configure signaling' });
  }
});

app.listen(port, () => {
  console.log(`Quick start running at http://localhost:${port}`);
});

The demo accepts two fixed-style peer IDs for clarity. In production, authenticate the application user and assign or validate the peer ID on the backend.

3. Add public/index.html

public/index.htmlHTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Xirsys two-peer demo</title>
  <style>
    body { font: 16px system-ui; max-width: 900px; margin: 2rem auto; }
    video { width: 46%; background: #111; margin-right: 1rem; }
    button { margin: 1rem .5rem 1rem 0; padding: .6rem 1rem; }
  </style>
</head>
<body>
  <h1>Xirsys two-peer demo</h1>
  <p>
    Open one tab as caller and a second as answerer. Allow camera access
    in both tabs, then select Start call in the caller tab.
  </p>
  <button id="caller">Join as caller</button>
  <button id="answerer">Join as answerer</button>
  <button id="start" disabled>Start call</button>
  <p id="status">Choose a role.</p>
  <video id="local" autoplay muted playsinline></video>
  <video id="remote" autoplay playsinline></video>

  <script>
    const statusNode = document.querySelector('#status');
    const startButton = document.querySelector('#start');
    const localVideo = document.querySelector('#local');
    const remoteVideo = document.querySelector('#remote');

    let role;
    let myPeerId;
    let remotePeerId;
    let socket;
    let peerConnection;
    let queuedCandidates = [];
    let heartbeatTimer;
    let pongTimer;
    let pendingPing = null;
    let missedPongs = 0;

    document.querySelector('#caller').onclick =
      () => join('caller').catch(showError);
    document.querySelector('#answerer').onclick =
      () => join('answerer').catch(showError);
    startButton.onclick = () => startCall().catch(showError);

    function showError(error) {
      console.error(error);
      statusNode.textContent = error.message || 'Unable to start the call';
      startButton.disabled = true;
    }

    async function join(selectedRole) {
      if (socket) return;

      role = selectedRole;
      myPeerId = role;
      remotePeerId = role === 'caller' ? 'answerer' : 'caller';
      statusNode.textContent = `Joining as ${role}...`;

      const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true
      });
      localVideo.srcObject = stream;

      const [iceResponse, signalResponse] = await Promise.all([
        fetch('/api/ice'),
        fetch(`/api/signal-config?peerId=${encodeURIComponent(myPeerId)}`)
      ]);
      if (!iceResponse.ok || !signalResponse.ok) {
        throw new Error('Unable to load Xirsys configuration');
      }

      const iceConfig = await iceResponse.json();
      const signalConfig = await signalResponse.json();

      peerConnection = new RTCPeerConnection(iceConfig);
      stream.getTracks().forEach(track => {
        peerConnection.addTrack(track, stream);
      });
      peerConnection.ontrack = event => {
        remoteVideo.srcObject = event.streams[0];
      };
      peerConnection.onicecandidate = event => {
        if (event.candidate) {
          send('candidate', { candidate: event.candidate });
        }
      };
      peerConnection.onconnectionstatechange = () => {
        statusNode.textContent =
          `${role}: ${peerConnection.connectionState}`;
      };

      socket = new WebSocket(
        `${signalConfig.host}/v2/${encodeURIComponent(signalConfig.token)}`
      );
      socket.onmessage = handleSignal;
      socket.onclose = () => {
        clearInterval(heartbeatTimer);
        clearTimeout(pongTimer);
        statusNode.textContent = 'Signaling disconnected. Reload to reconnect.';
      };
      socket.onopen = () => {
        statusNode.textContent = `${role} ready`;
        startButton.disabled = role !== 'caller';
        heartbeatTimer = setInterval(sendPing, 30_000);
      };
    }

    function send(operation, payload) {
      if (socket?.readyState !== WebSocket.OPEN) return;
      socket.send(JSON.stringify({
        t: 'u',
        m: { f: myPeerId, t: remotePeerId, o: operation },
        p: payload
      }));
    }

    async function startCall() {
      startButton.disabled = true;
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);
      send('offer', { description: peerConnection.localDescription });
    }

    async function handleSignal(event) {
      let message;
      try {
        message = JSON.parse(event.data);
      } catch {
        return;
      }

      if (message.t === 'pong' && message.ts === pendingPing) {
        clearTimeout(pongTimer);
        pendingPing = null;
        missedPongs = 0;
        return;
      }
      if (message.t !== 'u') return;

      if (message.m?.o === 'offer' && role === 'answerer') {
        await peerConnection.setRemoteDescription(message.p.description);
        await flushCandidates();
        const answer = await peerConnection.createAnswer();
        await peerConnection.setLocalDescription(answer);
        send('answer', { description: peerConnection.localDescription });
      } else if (message.m?.o === 'answer' && role === 'caller') {
        await peerConnection.setRemoteDescription(message.p.description);
        await flushCandidates();
      } else if (message.m?.o === 'candidate') {
        if (peerConnection.remoteDescription) {
          await peerConnection.addIceCandidate(message.p.candidate);
        } else {
          queuedCandidates.push(message.p.candidate);
        }
      }
    }

    async function flushCandidates() {
      for (const candidate of queuedCandidates) {
        await peerConnection.addIceCandidate(candidate);
      }
      queuedCandidates = [];
    }

    function sendPing() {
      if (
        socket.readyState !== WebSocket.OPEN ||
        pendingPing !== null
      ) return;

      pendingPing = Date.now();
      socket.send(JSON.stringify({ t: 'ping', ts: pendingPing }));
      pongTimer = setTimeout(() => {
        pendingPing = null;
        missedPongs += 1;
        if (missedPongs >= 2) {
          socket.close(4000, 'Heartbeat timeout');
        }
      }, 10_000);
    }
  </script>
</body>
</html>

4. Run the Call

TerminalShell
node server.js
  1. Open http://localhost:3000 in two tabs or devices.
  2. Select Join as caller in one and Join as answerer in the other.
  3. Wait until both show ready.
  4. Select Start call only in the caller tab.
  5. Inspect the browser console and chrome://webrtc-internals if negotiation does not complete.

The offer, answer, and candidates are private signaling messages because each includes m.t. A public message without m.t is delivered to every other connected subscriber on the channel; the sender does not receive an echo.

Production hardening: authenticate your own users, assign peer IDs server-side, authorize who may join each channel, apply rate limits, use HTTPS, reconnect with backoff and fresh signaling configuration, and derive geo-routing IPs only from trusted request context.

For endpoint details, continue to TURN (ICE), Signaling, and Secure API Calls.

Questions about this page? Email experts@xirsys.com