npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

doodle-rtc

v0.1.0

Published

Tiny copy-and-paste signaling for browser-to-browser WebRTC data channels

Readme

doodle-rtc - Microscopic Browser P2P Data Channels

WebRTC has made it possible for web browsers to connect directly to each other for a very long time. Once the connection is established, two browsers can exchange arbitrary data (files, voice, video) over a WebRTC data channel much like two desktop applications talking over a TCP or UDP socket. The hard part is getting the connection established in the first place. A simplified WebRTC handshake looks something like this:

  1. Alice generates an SDP "offer." This is a fairly large blob of text describing how Bob might be able to reach her, including ICE candidates, encryption parameters and other connection metadata.
  2. Alice has to put that offer somewhere Bob can retrieve it.
  3. Bob receives the offer, feeds it into WebRTC and generates an SDP "answer."
  4. Bob has to get that answer back to Alice.
  5. Alice and Bob establish a direct WebRTC connection. At this point, the system they used to exchange the offer and answer is no longer involved.

Steps 1, 3 and 5 are complicated internally, but browsers already know how to do them. The awkward part for application developers is steps 2 and 4. There are plenty of libraries that make WebRTC easier to use. Trystero, PeerJS, Peerix and similar projects can hide most of the details around ICE, STUN, TURN, NAT traversal and SDP negotiation. That is useful because raw WebRTC is a complicated API.

Before talking about those libraries, it is worth asking why we care about peer-to-peer applications in the first place. One property I care about is longevity. Properly built P2P apps can live for a really, really long time, even after their original maintainers lose interest. The same properties that make a network resistant to censorship, outages or interference can also make it resistant to neglect. There is no single server bill that has to keep getting paid forever, and this is where I think existing browser P2P has not yet fully matured.

The actual WebRTC data channel is decentralized, but many JavaScript P2P libraries hand-wave away rendezvous and session establishment. They quietly leave the question of how peers find each other to somebody else.

Bootstrapping is not a problem unique to WebRTC. Older P2P systems had to solve it too. Gnutella used mechanisms such as GWebCache and UDP host caches. BitTorrent eventually gained a DHT. These systems treated peer discovery as part of the network rather than something every application developer had to invent independently.

WebRTC P2P libraries tend to take one of two approaches to the rendezvous problem:

  1. They do nothing. The library gives you an offer and expects your application to deliver it somehow. Some projects provide a public signaling server for demos, but recommend running your own infrastructure for real applications. You must pick between paying a server bill forever, or hoping that the library maintainer keeps paying the bill for you.
  2. They use some existing centralized service as a signaling channel. This might be a publicly exposed MQTT broker, a WebTorrent tracker or some other general-purpose system that happens to be useful for exchanging WebRTC offers and answers.

My take is that the data-channel part of browser P2P is a solved problem, but rendezvous is not.

There are some solutions out there. Trystero paired with Nostr or IPFS is the cleanest thing I can find in 2026 that meets my definition of genuinely decentralized browser rendezvous. WebTorrent trackers can also be shoe-horned for this purpose, although in my experiments that approach has been extremely fragile. I do think there is still room for exploration here.

doodle-rtc is my exploration of this problem space. It explores the question of what if WebRTC offers and answers were small enough that we did not need a specialized signaling system at all?

Instead of treating signaling as a network service, what if an offer could comfortably fit inside an IRC message, a Bluesky DM, email or URL? Normal WebRTC SDP is far too large and awkward for that to be pleasant. doodle-rtc experiments with encoding only the information required for a small, constrained WebRTC data-channel connection. The resulting offer and answer codes are often around 90 to 200 URL-safe characters. Let's see how far that idea can be pushed.

The library is browser-only, has no runtime dependencies, and supports strings and ArrayBuffer messages.

Install

npm install doodle-rtc
import { DoodleRTC } from "doodle-rtc";

Your build target must provide the browser WebRTC globals, including RTCPeerConnection and RTCDataChannel.

Connect two browsers

Each side creates its own DoodleRTC. The first browser creates an offer, the second browser turns that offer into an answer, and the first browser accepts the answer.

1. Create an offer in browser A

import { DoodleRTC } from "doodle-rtc";

const peer = new DoodleRTC({
  onError(error) {
    console.error("Peer error:", error);
  },
  onCode(offerCode, state) {
    if (!state.complete) return;

    if (state.ipv4Candidates + state.ipv6Candidates === 0) {
      console.error("No usable network path was found");
      return;
    }

    showOfferToUser(offerCode);
  },
  onConnect() {
    console.log("Connected to browser B");
  },
});

peer.generateOffers();

Send the displayed offer code to browser B.

2. Answer in browser B

import { DoodleRTC } from "doodle-rtc";

const peer = new DoodleRTC({
  onError: console.error,
  onCode(answerCode, state) {
    if (state.complete && state.ipv4Candidates + state.ipv6Candidates > 0) {
      showAnswerToUser(answerCode);
    }
  },
  onConnect() {
    console.log("Connected to browser A");
  },
});

peer.generateAnswers(offerCodeFromBrowserA);

Send the displayed answer code back to browser A.

3. Accept the answer in browser A

Use the same DoodleRTC that created the offer:

peer.acceptAnswer(answerCodeFromBrowserB);

Both peers call their onConnect callbacks after the data channel opens.

Send and receive data

Pass message callbacks when creating the peer so you cannot miss an early message:

const peer = new DoodleRTC({
  onMessage(data) {
    if (typeof data === "string") {
      console.log("text:", data);
    } else {
      console.log("binary bytes:", data.byteLength);
    }
  },
  onConnect() {
    peer.send("hello");
    peer.send(new TextEncoder().encode("binary hello").buffer);
  },
});

Call send() only after onConnect fires. Call close() when the connection is no longer needed.

peer.close();

Signaling-code updates

onCode can run more than once. Browsers discover network candidates asynchronously, so every call contains the newest usable code and a snapshot of gathering progress:

const peer = new DoodleRTC({
  onCode(code, state) {
    renderCode(code);
    renderStatus({
      finished: state.complete,
      ipv4: state.ipv4Candidates,
      ipv6: state.ipv6Candidates,
    });
  },
});

For the simplest user experience, wait for state.complete and share the final code. If your interface shows an earlier code, replace it whenever onCode runs. Do not combine codes from different updates.

Optional/Advanced Configuration

By default, doodle-rtc uses Google's public STUN server. Production applications will often supply their own STUN service and WebRTC policy:

const peer = new DoodleRTC({
  rtcConfiguration: {
    iceServers: [{ urls: "stun:stun.example.com:3478" }],
  },
  dataChannelLabel: "sync",
  dataChannelOptions: {
    ordered: false,
    maxRetransmits: 0,
  },
});

dataChannelLabel defaults to "data". dataChannelOptions is used by the offerer when it creates the channel; the answerer receives that channel through WebRTC. Out-of-band negotiated channels (negotiated: true) are not supported.

This release encodes direct UDP server-reflexive paths only. TURN relay candidates, TCP candidates, media tracks, and multiple data channels are not supported. Adding a TURN server to rtcConfiguration does not add relay fallback to the signaling code.

API

new DoodleRTC(options?)

Creates one peer for one connection attempt. A peer can be either the offerer or the answerer and cannot be reused for another handshake.

peer.generateOffers()

Starts an offer. Read offer codes with onCode.

peer.generateAnswers(offerCode)

Consumes an offer and starts an answer. Read answer codes with onCode.

peer.acceptAnswer(answerCode)

Completes the offerer's handshake with the answer code. Call it on the same peer that previously called generateOffers().

peer.send(data)

Sends a string or ArrayBuffer after the channel opens.

peer.close()

Closes the data channel and its WebRTC connection. Calling it more than once is safe.

Event callbacks

const peer = new DoodleRTC({
  onCode(code, state) {},
  onConnect() {},
  onMessage(data) {},
  onError(error) {},
});

All callbacks are optional constructor properties:

  • onCode(code, state) — a signaling code or candidate update
  • onConnect() — the data channel is ready
  • onMessage(data) — a string or ArrayBuffer arrived
  • onError(error) — invalid use, handshake failure, or channel error

Operational failures are reported to onError instead of being thrown from the asynchronous public methods. Without it, the library writes the error to console.error.

When to use something else

Use a conventional signaling service when peers must find each other automatically, reconnect without user action, negotiate media, or work reliably across restrictive networks. Use a full WebRTC abstraction when you need TURN fallback, trickle ICE messages, multiple channels, streams, or long-lived session management.

Deep Dive

A WebRTC data channel is peer-to-peer after connection, but creating that connection is not automatic. The peers must first exchange:

  • ICE credentials, which authenticate connectivity checks;
  • a DTLS certificate fingerprint, which binds the encrypted transport to the peer described by signaling; and
  • ICE candidates, which describe network endpoints the other peer can try.

Browsers expose this information as SDP. A data-channel-only SDP offer is much larger than the information that varies from connection to connection. doodle-rtc extracts that variable data, serializes it into a compact binary message, and Base64URL-encodes the result. The receiver expands the message into an SDP description that RTCPeerConnection can consume.

There is no rendezvous server in this flow:

offerer                    human channel                    answerer
   |                                                          |
   | createOffer + gather candidates                          |
   |---- compact offer code --------------------------------->|
   |                                                          |
   |                    setRemoteDescription + createAnswer   |
   |<------------------- compact answer code -----------------|
   |                                                          |
   | setRemoteDescription                                     |
   |<========= ICE + DTLS + SCTP data channel ===============>|

The “human channel” can be any transport the application provides. The library does not copy, publish, expire, store, or otherwise manage codes.

Peer state machine

A DoodleRTC starts in new and takes exactly one role:

new -- generateOffers() --> offering -- acceptAnswer(answer) --> connecting --> connected
  \
   `- generateAnswers(offer) -------------------------------> connecting --> connected

any live state -- close() ------------------------------------> closed

Calling generateOffers() or generateAnswers() consumes the new peer. Calling acceptAnswer() is valid only on the offering peer. Invalid transitions are delivered through the configured onError callback because the public handshake methods start asynchronous browser work and return immediately.

The offerer creates the in-band data channel before generating its offer. The answerer receives that channel from RTCPeerConnection.ondatachannel. When the channel's open event fires, the library enters connected and calls the configured onConnect callback.

Offer and answer sequences

Offerer

  1. Construct an RTCPeerConnection from rtcConfiguration.
  2. Create one data channel using the configured label and options.
  3. Call createOffer() and setLocalDescription().
  4. Parse the local ICE credentials and SHA-256 DTLS fingerprint.
  5. Collect supported ICE candidates.
  6. Emit a new offer code whenever the supported candidate set changes.
  7. Decode the returned answer and reconstruct its SDP.
  8. Pass the answer to setRemoteDescription().

Answerer

  1. Decode the offer and verify that its role bit says offer.
  2. Reconstruct the offer SDP and pass it to setRemoteDescription().
  3. Call createAnswer() and setLocalDescription().
  4. Parse the answer's ICE credentials and DTLS fingerprint.
  5. Collect supported ICE candidates and emit updated answer codes.

Once each peer has a local and remote description, the browser performs ICE connectivity checks. A successful pair becomes the transport for DTLS, SCTP, and finally the data channel.

Candidate policy

The compact protocol deliberately accepts only candidates with all of these properties:

  • type srflx (server-reflexive);
  • transport udp;
  • an IPv4 or IPv6 address and a nonzero port.

A STUN server causes the browser to discover a server-reflexive address. For a typical IPv4 NAT, that is the public address and mapped UDP port observed by the STUN server:

192.168.1.20:5000 -- NAT --> 203.0.113.4:51823 -- STUN --> observed endpoint

IPv4 and IPv6 candidates are deduplicated, sorted by address and port, and capped at three per family. Sorting makes the same candidate set encode the same way regardless of discovery order.

Host, peer-reflexive, relay, and TCP candidates are ignored. In particular, TURN cannot provide a relay fallback with this wire format. This keeps codes small but means a connection can fail behind symmetric NATs, UDP-blocking firewalls, or other restrictive network topologies.

Why codes are emitted repeatedly

ICE gathering starts asynchronously after the local description is installed. The SDP credentials and fingerprint may be ready before any supported candidate arrives. Each accepted candidate changes the complete signaling message, so the library re-encodes and emits it.

CodeState.complete becomes true when the browser signals the end of candidate gathering. The two counts describe the exact candidate arrays in that emission. The code is a full snapshot, not a delta: the newest code replaces all previous codes.

Binary signaling format

All integers use network byte order. Text is UTF-8. The current layout is:

| Offset | Size | Value | | --- | ---: | --- | | 0 | 1 byte | IPv4 count, IPv6 count, answer bit, reserved bits | | 1 | 1 byte | ICE username-length delta and password-length delta | | 2 | variable | ICE username fragment | | variable | variable | ICE password | | variable | 32 bytes | raw SHA-256 DTLS fingerprint | | variable | 6 bytes each | zero to three IPv4 endpoints | | variable | 18 bytes each | zero to three IPv6 endpoints |

Header byte

bit       7 6       5 4       3       2 1 0
        +---------+---------+--------+----------+
byte 0  | v4 count| v6 count| answer | reserved |
        +---------+---------+--------+----------+

Each two-bit count represents zero through three candidates. The answer bit is zero for an offer and one for an answer. Decoders currently reject any nonzero reserved bit, leaving room for an explicitly incompatible future format.

Credential-length byte

ICE requires a username fragment of at least 4 characters and a password of at least 22 characters. The format stores four-bit length deltas:

bit       7 6 5 4       3 2 1 0
        +-------------+-------------+
byte 1  | ufrag - 4   | password-22 |
        +-------------+-------------+

The supported encoded lengths are therefore 4–19 bytes for the username fragment and 22–37 bytes for the password.

Fingerprint

The protocol fixes the DTLS fingerprint algorithm to SHA-256. SDP writes a fingerprint as 32 colon-separated hexadecimal octets; the compact form stores the 32 raw bytes and omits the fixed algorithm name and formatting.

Endpoints

An IPv4 endpoint is four address bytes followed by a two-byte port, for 6 bytes total. An IPv6 endpoint is sixteen address bytes followed by the port, for 18 bytes total. Candidate type, component, and transport are protocol constants and take no space per candidate.

For an 8-byte username fragment, a 24-byte password, one IPv4 candidate, and one IPv6 candidate, the complete binary message is 90 bytes:

headers                         2
ICE username fragment           8
ICE password                   24
SHA-256 fingerprint            32
IPv4 endpoint                   6
IPv6 endpoint                  18
                              ---
total                          90 bytes

Base64URL encodes those 90 bytes as 120 characters without padding. Its alphabet avoids +, /, and trailing =, so a code can be placed in URLs and copied through text systems with minimal escaping.

SDP reconstruction

After decoding, the library inserts the variable fields into a fixed, data-channel-only SDP template. It generates:

  • the session boilerplate and one bundled application media section;
  • ICE username and password attributes;
  • UDP server-reflexive candidate lines;
  • a SHA-256 DTLS fingerprint and role-appropriate setup attribute;
  • SCTP port 5000 and a maximum message size; and
  • an end-of-candidates marker.

Offer SDP uses DTLS setup role actpass; answer SDP uses active. Candidate foundations and priorities are generated deterministically. The reconstructed description is then supplied to the browser as an RTCSessionDescriptionInit.

This is why the format is smaller than compressed SDP: it does not represent the constant SDP text at all.

Data delivery

The library sets RTCDataChannel.binaryType to arraybuffer. Incoming strings remain strings; incoming binary messages are exposed as ArrayBuffer. It does not frame application messages, serialize objects, apply backpressure, retry application data, or impose an application protocol.

Reliability and ordering come from the offerer's RTCDataChannelInit options. The browser's defaults provide ordered, reliable delivery. Applications that send large or sustained streams should observe their own flow-control policy; DoodleRTC.send() is intentionally a thin interface. The channel uses WebRTC's in-band negotiation; negotiated: true is rejected because the answerer does not independently create a channel with an agreed SCTP stream ID.

Security properties

WebRTC encrypts the established data channel with DTLS. The fingerprint in the signaling code lets each browser verify that the DTLS certificate matches the peer described by signaling.

That does not authenticate the signaling channel itself. An attacker who can replace both offer and answer codes may substitute their own peers. Applications that require peer identity or tamper resistance should exchange codes through an authenticated channel or add an authenticated comparison step.

Codes also contain live ICE credentials and reachable endpoints. Treat them as short-lived connection capabilities: avoid logging them unnecessarily, do not reuse them, and close abandoned peers. The library deliberately leaves code expiration policy to the application.

Scope and extension points

The public configuration object can change the STUN server and ordinary RTCPeerConnection policies without changing the wire format. The data-channel label and initialization options affect the offerer's in-band channel.

Features such as TURN relays, TCP candidates, media, renegotiation, multiple channels, or automatic reconnection would require broader API and protocol decisions. They are outside the current compact format rather than hidden behind incomplete fallbacks.