Skip to main content

Reserved events

A handful of event names have library-defined semantics. Peers can't spoof them — the ctrl-channel onmessage filter drops them on receive.

EventWhere it firesArgsPurpose
peer-connectsocket.on(...)({ id })Ctrl DataChannel to a peer opened
peer-disconnectsocket.on(...)({ id })Peer connection closed (only after peer-connect fired)
track-addedsocket.on(...)({ peerId, stream, track })A new track joined an existing remote MediaStream
track-removedsocket.on(...)({ peerId, stream, track })A track was dropped from an existing remote MediaStream
peer-leftRTCIOBroadcastChannel.on(...)(peerId)One peer's per-channel underlying connection closed
open / close / error / drain / dataRTCIOChannel.on(...)variesChannel-level events
#rtcio:*internal onlyvariesLibrary signaling — never use

This page covers the user-visible ones (the first five). Internal #rtcio:* events are documented in the signaling protocol.

peer-connect

socket.on("peer-connect", ({ id }: { id: string }) => {
// Peer is ready for traffic.
});

Fires when the ctrl DataChannel to a peer opens. That's the practical "peer is reachable" signal — see Lifecycle events for the full flow.

Use it for:

  • Sending initial state to the new peer (socket.peer(id).emit("media-state", ...)).
  • Opening per-peer DataChannels that need symmetric creation on both sides (socket.peer(id).createChannel("file", ...)).
  • Acquiring per-peer resources (UI tile, stats poller, transfer slot).

peer-disconnect

socket.on("peer-disconnect", ({ id }: { id: string }) => {
// Peer connection is gone for good.
});

Fires when the peer connection closes — but only if peer-connect already fired. If a connection failed during the initial handshake (ICE never reached connected), no phantom peer-disconnect is emitted.

This pairing makes acquire/release patterns safe: every peer-disconnect you see has a matching peer-connect.

ICE restarts (transient network failures) do NOT fire peer-disconnect. The library calls restartIce() automatically and the connection self-heals. Only permanent close (manual disconnect, ICE failure with no recovery, tab close) triggers it.

track-added

socket.on("track-added", ({ peerId, stream, track }: {
peerId: string,
stream: MediaStream,
track: MediaStreamTrack,
}) => {
// A new kind of track arrived on an existing remote stream.
});

Fires when a track is added to an existing remote MediaStream after the initial ontrack has already happened. Useful for "they turned the camera on" UI changes after a peer started with audio only.

socket.on("track-added", ({ peerId, stream, track }) => {
if (track.kind === "video") {
showVideoTile(peerId, stream);
}
});

The library wires this up via MediaStream.onaddtrack on the receive side. The first track on a fresh stream is delivered via socket.on("camera", ...) (or whatever event the sender emitted with); only subsequent tracks fire track-added.

track-removed

socket.on("track-removed", ({ peerId, stream, track }: {
peerId: string,
stream: MediaStream,
track: MediaStreamTrack,
}) => {
// The remote peer dropped a track from this stream.
});

Fires when the WebRTC stack removes a track from a remote MediaStream — for example, the remote peer stopped a screen share, switched their camera off via removeTrack, or ended a transceiver. The event always pairs with the same stream argument the receiver originally got via socket.on("camera", ...) (or whichever event the sender emitted with), so you can correlate it back to your tile.

socket.on("track-removed", ({ peerId, stream, track }) => {
if (track.kind === "video" && stream.getVideoTracks().length === 0) {
hideVideoTile(peerId);
}
});

The library wires this up via MediaStream.onremovetrack — only platform-driven removals fire it. Your own stream.removeTrack(...) on a local copy does not.

track-removed is partial-departure detection (the peer is still there, they just dropped one track). For the peer leaving entirely, listen on peer-disconnect.

Reserved namespace

Any event name starting with #rtcio: is reserved for library internals. The ctrl-channel filter drops these on receive, so peers can't spoof them.

The full list:

EventDirectionCarrierPurpose
#rtcio:init-offerserver → clientsocket.ioTell an existing peer to initiate an offer to a newcomer
#rtcio:messagebidirectional via serversocket.ioMultiplexed envelope for offers, answers, candidates, stream-meta
#rtcio:peer-leftserver → clientsocket.ioHint that a socket has disconnected; the client uses it to shorten its WebRTC liveness watchdog
#rtcio:offerreservedReserved for future use
#rtcio:answerreservedReserved for future use
#rtcio:candidatereservedReserved for future use
#rtcio:stream-metareservedReserved for future use

#rtcio:init-offer, #rtcio:message and #rtcio:peer-left are the three the library actually uses. The others exist as constants on RtcioEvents but aren't currently emitted; they're reserved so future protocol changes can use them without breaking apps that listen to those names.

RtcioEvents constants

import { RtcioEvents } from "rtc.io";

RtcioEvents.OFFER; // "#rtcio:offer"
RtcioEvents.ANSWER; // "#rtcio:answer"
RtcioEvents.CANDIDATE; // "#rtcio:candidate"
RtcioEvents.MESSAGE; // "#rtcio:message"
RtcioEvents.STREAM_META; // "#rtcio:stream-meta"
RtcioEvents.INIT_OFFER; // "#rtcio:init-offer"
RtcioEvents.PEER_LEFT; // "#rtcio:peer-left"

Use these in server code instead of typing the strings:

socket.to(roomId).emit(RtcioEvents.INIT_OFFER, { source: socket.id });

The same constants are exported from rtc.io-server for server-side use.

Filter rationale

A peer that could spoof peer-connect could fire your acquire-on-connect handler for an arbitrary id, leaking resources. A peer that could spoof track-added or track-removed could fake track lifecycle events from someone who never shared one. The filter prevents all of them.

If you genuinely need to send a custom lifecycle event peer-to-peer, pick a non-reserved name (e.g. app:peer-up). Reserved names exist exactly because they're authoritative — only the local library is allowed to emit them.