RTCIOChannel
import type { RTCIOChannel } from "rtc.io";
const ch = socket.peer(id).createChannel("file", { ordered: true });
RTCIOChannel wraps a single RTCDataChannel between you and one peer. You don't construct it directly — you get one back from socket.peer(id).createChannel(name, options) or as the per-peer entries inside an RTCIOBroadcastChannel.
Sending
emit(name, ...args)
ch.emit(eventName: string, ...args: any[]): void
Sends a JSON envelope { e: name, d: args }. Receivers handle it via ch.on(name, ...). Same idiom as socket.emit, scoped to this channel:
ch.emit("hello", { from: "alice" });
ch.emit("update", 1, 2, 3); // multi-arg
A trailing function argument (the socket.io ack idiom) is dropped with a warning.
send(data)
ch.send(data: ArrayBuffer | string): boolean
Send raw bytes or a raw string. Used for streaming binary blobs (file chunks, codec output) where the JSON envelope shape doesn't fit.
- Returns
true— chunk accepted. Either sent on the wire (channel open, room underhighWatermark) or queued in JS (channel still connecting, or above the high-water mark). The library flushes queued chunks on'open'/'drain'. - Returns
false— chunk dropped. The JS queue is atqueueBudgetand the chunk was not buffered.'error'also fires. Wait for'drain', then retry the same buffer.
const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
while (!ch.send(buf)) {
await new Promise((r) => ch.once("drain", r));
}
See Backpressure & flow control for the full pattern.
Receiving
on(event, handler) / off(event, handler) / once(event, handler)
ch.on(event: string, handler: (...args: any[]) => void): this
ch.off(event: string, handler: (...args: any[]) => void): this
ch.once(event: string, handler: (...args: any[]) => void): this
Standard EventEmitter-style listener registration. once auto-removes the handler after the first invocation.
Three special event names are dispatched by the library itself:
| Event | Args | Fires when |
|---|---|---|
open | none | Channel opened (SCTP up, ready to send) |
close | none | Channel closed (peer left, you called close, transport died) |
error | (err) | Channel error or queue-budget overrun |
data | `(buf: ArrayBuffer | string)` |
drain | none | bufferedAmount fell below lowWatermark (1 MB by default; configurable via ChannelOptions) |
Plus any event name you've emited: ch.emit("chat", msg) → ch.on("chat", (msg) => ...).
ch.on("open", () => console.log("ready"));
ch.on("close", () => console.log("gone"));
ch.on("error", (e) => console.error("channel error:", e));
ch.on("data", (chunk) => receiver.push(chunk));
ch.on("drain", () => console.log("buffer drained"));
ch.on("msg", (text) => append(text)); // user-defined event
State
readyState: RTCDataChannelState
"connecting" | "open" | "closing" | "closed"
Live property. Mirrors RTCDataChannel.readyState; if the channel hasn't been attached yet (rare), defaults to "connecting".
bufferedAmount: number
Live property. Mirrors RTCDataChannel.bufferedAmount — bytes queued in the browser's transport, not yet sent. Use this if you're implementing your own throttling on top of (or instead of) the built-in watermark/drain pattern:
const PAUSE_AT = 16 * 1024 * 1024; // matches the default highWatermark
if (ch.bufferedAmount > PAUSE_AT) {
// back off
}
The defaults are highWatermark: 16 MB and lowWatermark: 1 MB; both are overridable per-channel via ChannelOptions.
Closing
close()
ch.close(): void
Closes the underlying RTCDataChannel and clears any queued payloads. Fires close on both ends.
If the channel is already in closing or closed state, this is a no-op.
Watermarks and queue budget
Three knobs govern how much the channel will buffer before refusing or draining:
| Default | Option | Role |
|---|---|---|
| 16 MB | highWatermark | bufferedAmount + chunkSize > this → the library moves the chunk to the JS queue instead of pushing it straight onto the wire. send() still returns true. |
| 1 MB | lowWatermark | bufferedAmount falls back through this → library flushes the JS queue and emits 'drain'. Forwarded to RTCDataChannel.bufferedAmountLowThreshold. |
| 1 MB | queueBudget | Hard cap on the JS-side queue. Exceeding it drops the chunk, returns false from send(), and fires 'error'. |
All three are configurable per-channel:
const ch = socket.peer(id).createChannel("file", {
queueBudget: 32 * 1024 * 1024, // 32 MB held in JS until the DC accepts
highWatermark: 32 * 1024 * 1024, // pause threshold matched to budget
lowWatermark: 8 * 1024 * 1024, // drain fires at 8 MB
});
Keep lowWatermark below highWatermark — otherwise the bufferedamountlow event fires immediately on every send and the throttling collapses. See Backpressure & flow control and ChannelOptions for the tuning guide.
Internal: _attach(dc) / _isAttached()
These are library internals you'll see in stack traces. They wire the wrapper to an underlying RTCDataChannel. You don't call them.
Patterns
Backpressure-aware streaming send
async function streamFile(ch, file) {
if (ch.readyState !== "open") {
await new Promise((r) => ch.once("open", r));
}
const CHUNK = 16 * 1024;
for (let offset = 0; offset < file.size; offset += CHUNK) {
const buf = await file.slice(offset, offset + CHUNK).arrayBuffer();
while (!ch.send(buf)) {
// send() returned false — chunk was dropped. Wait, then retry.
await new Promise((r) => ch.once("drain", r));
}
}
ch.emit("eof");
}
Receiver assembling chunks
const chunks: ArrayBuffer[] = [];
ch.on("data", (chunk) => chunks.push(chunk));
ch.on("eof", () => {
const blob = new Blob(chunks, { type: "application/octet-stream" });
download(blob);
});
Channel-scoped pub/sub
const ch = socket.createChannel("game-events", { ordered: false });
ch.on("position", (p) => updatePeer(p));
ch.on("score", (s) => bumpScore(s));
ch.emit("position", { x, y });
ch.emit("score", 1);
Error handling
error fires for two reasons:
- The underlying
RTCDataChannelraised anerrorevent — usually a transport problem. - You exceeded the queue budget while the channel was queueing —
RTCIOChannel: queue budget exceeded — chunk dropped, wait for 'drain' and retry. The chunk that triggered this is not buffered; thesend()call also returnedfalse. Retry the same buffer after'drain', or raisequeueBudgetto give the sender more headroom.
If a send/emit triggers an exception in the underlying transport (rare; usually means the channel was closed mid-call), error fires with the underlying error and send returns false.
What's not on RTCIOChannel
- No ack callbacks. DataChannels don't have them. If you need confirmation, encode it in your protocol.
- No "pause"/"resume" methods. The watermark + drain pattern is the API.
- No re-open after close. If you want a fresh channel, call
createChannel(name)again — the library returns the existing instance if the channel is still alive, otherwise opens a new one.
Live example
A full file transfer with the send() / 'drain' backpressure contract — the chunk-and-await pattern that lets you ship multi-GB files without OOMing the tab.