Skip to main content

RTCIOStream

import { RTCIOStream } from "rtc.io";

const stream = new RTCIOStream(mediaStream);
// or
const stream = new RTCIOStream(stableId, mediaStream);

RTCIOStream is a thin wrapper around a MediaStream that gives the library a stable identity to track per-stream. Both peers see the same id after the first replay, so you can correlate streams to people across the connection.

Constructor

new RTCIOStream(mediaStream: MediaStream)
new RTCIOStream(id: string, mediaStream: MediaStream)

The single-arg form auto-generates a UUID for the id. The two-arg form lets you provide a stable identifier (useful if you want a stream's identity to survive page reload).

const camera = new RTCIOStream(localMedia);
console.log(camera.id); // "550e8400-e29b-41d4-a716-446655440000"

const stable = new RTCIOStream("alice-camera", localMedia);
console.log(stable.id); // "alice-camera"

Properties

id: string

The stream's identity on the wire. After the first send, the receiver's local RTCIOStream adopts the sender's id (so both sides agree).

camera.id; // sender side: locally-generated UUID
remoteCamera.id; // receiver side: same UUID after the first track lands

mediaStream: MediaStream

The underlying browser MediaStream. Use it for <video>.srcObject, getTracks(), anything you'd do with a normal MediaStream.

videoEl.srcObject = camera.mediaStream;
camera.mediaStream.getAudioTracks()[0].enabled = false; // mute

Methods

addTrack(track) / removeTrack(track)

Pass-throughs to the underlying MediaStream. The wrapper listens to the MediaStream's addtrack/removetrack events, so calling these will trigger onTrackChanged callbacks (which the library uses internally to keep transceivers in sync).

const newAudio = (await navigator.mediaDevices.getUserMedia({ audio: true })).getAudioTracks()[0];
camera.removeTrack(camera.mediaStream.getAudioTracks()[0]);
camera.addTrack(newAudio);
// onTrackChanged fires; rtc.io reuses the existing audio transceiver via replaceTrack.

replace(stream)

Replace all tracks with the tracks from stream. Removes existing ones, adds the new ones. Each removal/addition triggers the onTrackChanged callbacks.

const fresh = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
camera.replace(fresh);

onTrackChanged(callback)

onTrackChanged(callback: (stream: MediaStream) => void): () => void

Register a callback that fires whenever a track is added to or removed from the underlying MediaStream. Returns an unsubscribe function.

This is what the library uses internally to react to track swaps and call replaceTrack on the existing RTCRtpSender. You can use it from app code if you want to react to track changes (e.g. update a "camera is on" indicator):

const off = camera.onTrackChanged((stream) => {
const hasVideo = stream.getVideoTracks().length > 0;
setCameraOn(hasVideo);
});

// Later:
off();

onTrackAdded(callback) / onTrackRemoved(callback)

onTrackAdded(callback: (track: MediaStreamTrack) => void): () => void
onTrackRemoved(callback: (track: MediaStreamTrack) => void): () => void

Per-track variants of onTrackChanged. They fire when the platform mutates the stream (e.g. the WebRTC stack delivers a new remote track, or drops one when the remote stops sending) and hand you the specific MediaStreamTrack involved. Programmatic addTrack / removeTrack on a local copy does not fire these — for the user-driven case, use onTrackChanged.

Each returns an unsubscribe function. Callbacks are also cleared on dispose(), so internal listeners cannot outlive the wrapper.

const off = remoteStream.onTrackRemoved((track) => {
console.log("remote dropped a", track.kind, "track");
});

These are the primitives behind the receive-side track-added and track-removed events; you usually want those instead unless you're holding a stream wrapper directly.

toJSON()

Returns the wire-format string "[RTCIOStream] <id>". The library uses this when serializing stream metadata. Receivers detect this string in incoming JSON and substitute back the local RTCIOStream instance.

You'd normally not call this yourself.

Sending an RTCIOStream

socket.emit with an RTCIOStream (or any object containing one) routes through transceivers, not the ctrl channel:

socket.emit("camera", new RTCIOStream(localMedia));
socket.emit("camera", { id: socket.id, name: "alice", camera: new RTCIOStream(localMedia) });
socket.emit("screen", new RTCIOStream(displayMedia));

The library deep-walks args looking for any RTCIOStream instance. If found:

  1. The stream is added to the replay registry (so late joiners get it).
  2. For every connected peer, addTransceiver is called for each track.
  3. The browser fires onnegotiationneeded; rtc.io creates a fresh offer.

Per-peer emit is also supported for sending a stream to one specific peer:

socket.peer(targetId).emit("private-cam", new RTCIOStream(localMedia));

This skips the replay registry — only that peer gets it, and late joiners don't.

Attaching metadata to a stream

The RTCIOStream doesn't have to be the only arg. The library deep-walks the payload, swaps each RTCIOStream for its wire token ("[RTCIOStream] <id>"), and reconstructs the original shape on the receive side — so anything else you put in the payload (object, array, primitive) rides along verbatim. Use this to ship the display name, the kind of stream, the source app, whatever the receiver needs to render the tile correctly:

socket.emit("stream", {
screen: new RTCIOStream(displayStream),
metadata: { userId: "abc123", displayName: "Alice", kind: "screen" },
});
socket.on("stream", (payload: {
screen: RTCIOStream;
metadata: { userId: string; displayName: string; kind: "camera" | "screen" };
}) => {
video.srcObject = payload.screen.mediaStream;
label.textContent = payload.metadata.displayName;
console.log("from", payload.metadata.userId);
});

The metadata is stored with the stream in the replay registry, so a peer who joins later receives the same { screen, metadata } payload without you doing anything. Update by emitting again with the same RTCIOStream instance and a fresh metadata object — the registry overwrites by stream id.

A few rules:

  • Only RTCIOStream instances are detected, not bare MediaStream. A MediaStream in the payload JSON-serialises to {} and the receiver gets nothing.
  • The wrapper instance must be stable. Hold one RTCIOStream per underlying media for the whole session — a fresh wrapper on every emit creates a new stream id and registers a duplicate.
  • Don't put functions, class instances (other than RTCIOStream), or non-JSON values in the metadata. The payload goes through JSON.stringify once the stream tokens are swapped in.

Receiving an RTCIOStream

The receive side handler shape mirrors the emit:

socket.on("camera", (cam) => {
// cam is an RTCIOStream
videoEl.srcObject = cam.mediaStream;
});

socket.on("camera", ({ id, name, camera }) => {
// structured arg shape mirrors what was emitted
videoEl.srcObject = camera.mediaStream;
label.textContent = name;
});

The wrapper you receive is a fresh RTCIOStream constructed by the library, with the same id as the sender's instance. You can call onTrackChanged on it to react to track changes (e.g. peer turned camera on after starting with audio only — fires the track-added event too).

Lifecycle

The library auto-replays registered streams to new peers — so once you emit, the stream is "live" for the rest of the session. To stop replaying (e.g. user stopped sharing screen), call:

socket.untrackStream(myStream);

This drops it from the registry. Already-connected peers still have the transceivers; you'll need to either stop() the underlying tracks or replaceTrack(null) if you want media to actually stop flowing. See Streams for the full pattern.

dispose()

dispose(): void

Detaches the wrapper's platform-event listeners and clears all registered onTrackChanged / onTrackAdded / onTrackRemoved callbacks. Use it when you're done with the wrapper but the underlying MediaStream lives on — e.g. you handed it to a <video> element and the wrapper would otherwise pin closures referencing the (now-dead) peer.

The library calls this for you on every inbound stream when its peer disconnects, so you almost never need to call it yourself.

Common pitfalls

  • Don't pass the raw MediaStream to socket.emit. It looks like it would work but the library only detects RTCIOStream instances. Wrap with new RTCIOStream(media).
  • Don't construct a new wrapper on every render. Identity matters — re-emitting a fresh wrapper would create a new id and start a fresh stream registration. Hold onto one wrapper for the lifetime of the underlying media.
  • Track changes vs replace. replaceTrack (track-level) doesn't fire addtrack/removetrack on the MediaStream — only addTrack/removeTrack (stream-level) do. The library uses the latter for its callback wiring, so swap tracks via removeTrack + addTrack if you want onTrackChanged to fire.

Live examples

Minimal: socket.emit('camera', new RTCIOStream(local))

Minimal video
Click 'Open 2nd tab ↗' inside the preview to bring a peer online.
src/main.ts
import io, { RTCIOStream } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

// setupRoom() reads ?room=… from the URL or mints a fresh UUID, picks a
// guest-XXXX display name, and renders the "Open 2nd tab" button in the
// corner so you can spawn a peer in one click. See src/room.ts.
const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Minimal video call · room <code>${ROOM}</code></h1>
<p><small>Click <strong>Open 2nd tab ↗</strong> in the corner to spawn a peer.</small></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<video id="local" autoplay playsinline muted style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
<video id="remote" autoplay playsinline style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
</div>
<p id="status" style="margin-top:12px"><small>Connecting…</small></p>
</div>`;

const localEl = document.getElementById('local') as HTMLVideoElement;
const remoteEl = document.getElementById('remote') as HTMLVideoElement;
const status = document.getElementById('status')!;

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

const local = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localEl.srcObject = local;
const camera = new RTCIOStream(local);

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// You can ship app metadata alongside the stream — the library walks args
// looking for any RTCIOStream and preserves the rest of the shape verbatim.
socket.emit('camera', { stream: camera, metadata: { displayName: NAME } });

socket.on('camera', (payload: { stream: RTCIOStream; metadata: { displayName: string } }) => {
remoteEl.srcObject = payload.stream.mediaStream;
status.innerHTML = `<small>Connected · streaming P2P from ${payload.metadata.displayName}</small>`;
});

socket.on('peer-connect', ({ id }) => console.log('peer joined', id));
socket.on('peer-disconnect', ({ id }) => {
console.log('peer left', id);
status.innerHTML = '<small>Peer left. Open another tab to reconnect.</small>';
});

Late-joiner replay + untrackStream

The library keeps a registry of every RTCIOStream you emit and replays them to peers that join later. Calling socket.untrackStream(s) drops the entry — the stream stops being replayed to brand-new peers, but already-connected peers are unaffected.

Screen share that survives a late joiner
Click 'Share screen' in tab #1, THEN open tab #2 — the share lands instantly because emit() registers the stream for replay.
src/main.ts
import io, { RTCIOStream } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Late-joiner stream replay · room <code>${ROOM}</code></h1>
<p>
<small>Click <strong>Share screen</strong> in tab #1, then hit <strong>Open 2nd tab ↗</strong>.<br>
Tab #2 sees the screen share immediately even though it joined late — the library
replays registered streams to every new peer.</small>
</p>
<button id="share">Share screen</button>
<button id="stop" disabled>Stop sharing</button>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px">
<video id="local" autoplay playsinline muted style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
<video id="remote" autoplay playsinline style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
</div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;

const localEl = document.getElementById('local') as HTMLVideoElement;
const remoteEl = document.getElementById('remote') as HTMLVideoElement;
const shareBtn = document.getElementById('share') as HTMLButtonElement;
const stopBtn = document.getElementById('stop') as HTMLButtonElement;

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

let myStream: RTCIOStream | null = null;

shareBtn.addEventListener('click', async () => {
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
myStream = new RTCIOStream(display);
localEl.srcObject = display;
// emit() is enough — late joiners auto-receive this stream because the
// library keeps a replay registry keyed by the stream's id.
socket.emit('screen', myStream);
shareBtn.disabled = true;
stopBtn.disabled = false;

display.getVideoTracks()[0].addEventListener('ended', () => stopBtn.click());
});

stopBtn.addEventListener('click', () => {
if (!myStream) return;
myStream.mediaStream.getTracks().forEach((t) => t.stop());
// untrackStream drops it from the replay registry so peers joining AFTER
// we stop sharing don't see a dead stream attached.
socket.untrackStream(myStream);
myStream = null;
localEl.srcObject = null;
shareBtn.disabled = false;
stopBtn.disabled = true;
});

socket.on('screen', (s: RTCIOStream) => {
remoteEl.srcObject = s.mediaStream;
});