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

@lightningrodlabs/webrtc-peer

v0.3.0

Published

Managed WebRTC peer connection for the browser: W3C Perfect Negotiation, a connection-lifecycle state machine, and a pluggable reconnection engine.

Readme

@lightningrodlabs/webrtc-peer

Bring your own P2P transport. Get a managed RTCPeerConnection per peer.

Give the library two callbacks — one to send a signal, one that you call when a signal arrives — and it takes care of establishing the RTCPeerConnection, keeping it alive, and surfacing the media. No signal server required, no assumptions about how the bytes get there.

const manager = new ConnectionManager({
  myAgentId: myPeerId,
  signaling: (to, msg) => myP2P.send(to, JSON.stringify(msg)),
});

myP2P.onMessage((from, raw) =>
  manager.deliverSignal(from, JSON.parse(raw)),
);

manager.ensureConnection(otherPeerId);
manager.on('remote-stream', ({ remoteAgent, data }) => attachVideo(remoteAgent, data));

That is the complete integration. Works with Holochain remote signals, libp2p streams, a plain WebSocket — anything that can ship an opaque JSON blob from one peer to another.

Why this exists

Raw RTCPeerConnection gives you primitives, not a connection. To ship reliable P2P media you have to solve, yourself:

  • Offer collisions (glare). Both peers negotiating at once corrupts signaling state. The W3C Perfect Negotiation pattern handles this.
  • Reconnection. ICE paths drop. Knowing when to ICE-restart vs. full-reconnect, with backoff, is non-trivial — and tearing down too eagerly lands you back on the same broken path.
  • Lifecycle. ICE / DTLS / signaling / data-channel states each have their own machine; your UI needs one coherent answer to "what is this connection doing?"
  • Forensics. When a connection fails in the field, you need a structured trail, not scattered console logs.

This library solves these once, behind a small signaling-agnostic API. It does not do peer discovery, identity, authentication, or signal transport — those belong to your P2P substrate. The library cares only about WebRTC, and trusts your transport to deliver SDP and ICE messages between two known peers.

What you get

  • Perfect Negotiation — W3C polite/impolite pattern, glare handling, ICE-candidate queueing, trickle and non-trickle ICE.
  • Lifecycle FSM — one ConnectionPhase (idle → signaling → connecting → connected → reconnecting → disconnected → failed → closed) with guarded transitions. Subscribe to phases, not raw browser states. connected means media-ready (ICE + DTLS up — RTP flows); the data channel is a separate signal (ConnectionViewModel.dataChannelReady / the data-channel-open event), not a gate, and a stuck channel is recovered in place in the background.
  • Two-tier reconnection — fast ICE-restart first, then full reconnect, with quadratic backoff + jitter. Bring your own ReconnectPolicy to override.
  • Multi-peer ConnectionManager — one object owns every peer, routes signals, propagates local media, exposes an aggregate view model for room UI.
  • Reactive view models — phase, progress, retry context, connection quality (relayed? candidate type?), track flow, a dataChannelReady flag, a composite healthy flag.
  • Structured forensics — every transition emits an FSMTransitionEntry with a full TransportSnapshot (ICE / DTLS / signaling / gathering / data-channel). TransitionRecorder captures a ring buffer you can dump on failure.
  • onPeerCreated hook — get the bare RTCPeerConnection before any tracks are attached, to install simulcast transceivers, codec preferences, etc.
  • Zero runtime dependencies. Browser WebRTC APIs only. Fully typed. Testable — inject a mock RTCPeerConnection via createPeerConnection.

Footprint & tiers

The package is layered, and you depend only on the tier you import — the rest tree-shakes away ("sideEffects": false, ESM):

| Import | You get | Approx. source | |--------|---------|---------------| | @lightningrodlabs/webrtc-peer/core | RTCPeer — Perfect-Negotiation wrapper only; bring your own state/retry | ~550 lines | | @lightningrodlabs/webrtc-peerPeerConnectionFSM | one peer + lifecycle FSM + reconnection | ~1.2k lines | | @lightningrodlabs/webrtc-peerConnectionManager | full multi-peer mesh + view models | full package |

RTCPeer imports nothing but types; PeerConnectionFSM builds on it; ConnectionManager builds on that. So import { RTCPeer } does not pull in the FSM or manager. The /core subpath makes the smallest tier explicit, but the root import tree-shakes to the same result.

Install

npm install @lightningrodlabs/webrtc-peer

Plugging into a P2P transport

The library accepts signaling in either of two forms. Pick the one that matches your transport's shape.

Send-callback form (recommended for most P2P transports)

P2P substrates typically deliver messages via "I receive a message, dispatch it" — not a subscription model. Pass a SignalSender function and call manager.deliverSignal(from, message) when an inbound message arrives:

import { ConnectionManager } from '@lightningrodlabs/webrtc-peer';

const manager = new ConnectionManager({
  myAgentId: 'my-stable-peer-id',
  signaling: (to, msg) => myP2P.send(to, JSON.stringify(msg)),
});

myP2P.onMessage((from, raw) => manager.deliverSignal(from, JSON.parse(raw)));

Holochain remote signals

const manager = new ConnectionManager({
  myAgentId: encodeHashToBase64(myAgentPubKey),
  signaling: (to, msg) =>
    roomClient.sendMessage([decodeHashFromBase64(to)], 'Sdp', JSON.stringify(msg)),
});

// in your AppSignal handler:
if (signal.type === 'Sdp') {
  manager.deliverSignal(encodeHashToBase64(signal.from), JSON.parse(signal.payload));
}

Adapter form

If your transport already exposes a clean subscription API, implement SignalingAdapter and pass that instead. The library will call onSignal itself.

const manager = new ConnectionManager({
  myAgentId,
  signaling: {
    sendSignal(to, msg) { /* ... */ },
    onSignal(handler) { /* return unsubscribe */ },
  },
});

What the library expects of your transport

Minimal. The wire format is a small SignalMessage JSON envelope (offer | answer | candidate | leave) that you ship opaquely between two peers.

  • Authenticated peer identity. You give the library a stable from per inbound signal. The library uses string comparison on peer ids to assign polite/impolite roles. Identity is your transport's job.
  • Agent-to-agent delivery. No broadcast required. The library only ever sends to one peer at a time.
  • Best-effort, not exactly-once. The library tolerates loss, reordering and duplicates. A connection-scoped peerSessionId filters stale signals from previous peer sessions.

You do not need:

  • A reliable ordered channel.
  • A signal server, relay, or rendezvous service.
  • Anything beyond "deliver this byte string to that peer."

Reconnection

DefaultReconnectPolicy uses quadratic backoff with jitter and a two-tier strategy: the first attempts use ICE restart (fast, preserves DTLS), then it switches to full reconnect; DTLS failures go straight to full reconnect (a fresh DTLS handshake needs a new transport). Every knob is a documented constructor option — nothing is hard-wired:

new ConnectionManager({
  myAgentId,
  signaling: send,
  reconnectPolicy: new DefaultReconnectPolicy({
    maxAttempts: Infinity,     // retry until you close the connection
    iceRestartMaxAttempts: 3,  // fast-path attempts before full reconnect
    baseDelayMs: 300,
    maxDelayMs: 7_000,
    jitterMs: 1_000,
  }),
});

The WebRTC spec mandates no retry count or backoff — this is application policy. A fixed maxAttempts can give up mid-outage (a VPN flap or Wi-Fi→cellular handover can exceed the backoff window). If a higher layer already knows when a peer is still wanted (e.g. room membership), prefer maxAttempts: Infinity and closeConnection() when the peer leaves, rather than relying on a count. Or replace the policy entirely with any ReconnectPolicy implementation.

How a failed connection is handled

connectionState === 'failed' aggregates the ICE and DTLS transports. The FSM reads the underlying states to attribute it: an ICE-transport failure recovers via ICE restart, a DTLS-transport failure via full reconnect. Neither is terminal — failed is reached only when the configured retry count is exhausted. The raw ice=…/dtls=… states are embedded in the transition trigger (and in the structured TransportSnapshot) for diagnosis.

Ownership: who drives recovery

For a single-transport app, let the library recover: ICE restart, full reconnect, backoff and the disconnected-grace window are self-contained. Don't run a second recovery loop racing the FSM (watching pc.iceConnectionState and re-initiating) — that produces "media flows briefly, then reconnects repeatedly" churn. Subscribe to connection-state-changed for UI, express give-up via a finite maxAttempts (FSM lands in failedidle; re-trigger via ensureConnection when the peer returns) or maxAttempts: Infinity + closeConnection() on your own "peer gone" signal, and don't reach into the pc.

Multi-transport apps are different. If a higher layer chooses among several carriers (e.g. WebRTC and a signals/relay fallback) and flips between them, that orchestrator — not the FSM — owns the give-up and the timing. It needs WebRTC to fail fast and yield so it can switch carriers, which is the opposite of persistent in-FSM recovery. For that case:

  • keep maxAttempts low (or even disable full-reconnect via a custom ReconnectPolicy) so the FSM surfaces failure quickly, and
  • let the orchestrator own teardown (closeConnection) and carrier selection.

A persistent FSM fighting a carrier orchestrator churns endlessly and starves the fallback path — so match the policy to which layer is in charge.

Configuring the RTCPeerConnection

ConnectionConfig.iceServers carries STUN/TURN servers. ICE handles relay fallback automatically when host/srflx paths fail. Set iceTransportPolicy: 'relay' to force TURN-only. iceCandidatePoolSize (default 1) pre-gathers candidates so they're ready at offer time, trimming establishment latency on slow signaling paths.

Media direction is implicit: attach a stream with only an audio track to send audio only, omit a stream entirely to send nothing (the data channel still forms). To receive a kind you never send (recvonly), or to pin a direction up front, pre-create the transceiver in onPeerCreated:

onPeerCreated: ({ pc }) => {
  pc.addTransceiver('video', { direction: 'recvonly' }); // receive, never send
};

For lower-level configuration (simulcast, codec preferences, custom transceivers), onPeerCreated fires once per peer session with the bare RTCPeerConnection, before any local tracks are attached:

const manager = new ConnectionManager({
  myAgentId,
  signaling: send,
  onPeerCreated: ({ pc, remoteAgent }) => {
    pc.addTransceiver('video', {
      direction: 'sendrecv',
      sendEncodings: [
        { rid: 'h', maxBitrate: 1_200_000 },
        { rid: 'm', maxBitrate: 300_000, scaleResolutionDownBy: 2 },
        { rid: 'l', maxBitrate: 100_000, scaleResolutionDownBy: 4 },
      ],
    });
  },
});

Forensics

onTransition is the firehose: one structured FSMTransitionEntry per transition, each carrying timestamp, connection id, from/to phase, trigger string, peer-session id, and a TransportSnapshot of all underlying browser states. TransitionRecorder keeps the last N entries; dump() / toJSON() produce a portable record for bug reports. The library never writes to console — pass a logger (Logger) if you want recovered errors and warnings surfaced.

Verbose library-internal instrumentation (DTLS-watchdog bookkeeping, timer cancellation) is off by default and emitted as DIAG:-prefixed entries only when you set config.diagnostics = true. Leave it off in production; turn it on when diagnosing connection-establishment or DTLS-stall issues. Real connection events (ICE state, dropped stale signals, new peer sessions) are always emitted.

The establishment-timeline event (on the FSM and ConnectionManager) emits one structured EstablishmentTimeline record per connect, with per-stage durations (ICE / DTLS / connected / data channel), a wasReconnect flag, and the peerSessionId — a compact way to track establishment latency without parsing the transition firehose.

const recorder = new TransitionRecorder({ capacity: 500 });
const manager = new ConnectionManager({
  myAgentId,
  signaling: send,
  onTransition: (entry) => recorder.record(entry),
});

window.onerror = () => navigator.clipboard.writeText(recorder.toJSON());

Lower-level API

ConnectionManager is the recommended entrypoint. For a single connection or custom orchestration, use PeerConnectionFSM (one peer, full lifecycle) or RTCPeer (a thin Perfect-Negotiation wrapper over RTCPeerConnection) directly.

Platforms

Targets the W3C RTCPeerConnection API. Works in any browser, in Electron's renderer process, and — via react-native-webrtc's globalThis polyfills — in React Native. The createPeerConnection factory lets you inject a non-global constructor explicitly if needed.

Testing

The library never constructs an RTCPeerConnection directly — it goes through an injectable createPeerConnection factory. Pass a mock to run the full FSM headless in Node, with no browser or DOM environment. See src/__tests__/ for the suite (160+ tests, including two-peer integration).

License

MIT