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

@rivalis/node

v8.0.0

Published

Node.js RTCTransport + RTCClient for Rivalis (WebRTC host and peer over node-datachannel)

Downloads

90

Readme

@rivalis/node

Node.js RTCTransport and RTCClient for Rivalis — WebRTC data channels over node-datachannel.

@rivalis/node lets a Node process act as a P2P game host (RTCTransport) or as a Node peer (RTCClient) connecting to a host. Game logic is unchanged: Room subclasses written for WSTransport run over WebRTC without modification.

peer (RTCClient)           @rivalis/signal           host (RTCTransport)
  │  WS connect + ticket ──────────►│                       │
  │◄── signal:welcome {iceServers} ──────────────────────────│
  │  signal:offer ─────────────────►│──── relay ────────────►│
  │◄─────────── signal:answer ───────────────────────────────│
  │  ICE (trickle, both ways via SignalRoom)                  │
  │═══════════════ DataChannel OPEN (DTLS-encrypted) ════════│
  │◄══════════════ game frames (handshake codec) ════════════►│

After the channel opens, the signal server sees zero game traffic.

Install

npm install @rivalis/node

Peer dependencies (must be installed by the host application):

"@rivalis/core": ">=8 <9",
"@rivalis/handshake": ">=8 <9",
"@toolcase/base": "3.x",
"@toolcase/logging": "3.x",
"ws": "8.x"

node-datachannel is a direct dependency (prebuilt native binary) and provides the WebRTC backend.

Quick start — host

import { Rivalis } from '@rivalis/core'
import { WSTransport } from '@rivalis/core/transports/ws'
import { RTCTransport } from '@rivalis/node'

// Same Room subclass works over both transports — no changes.
const rivalis = new Rivalis<ActorData>({
    transports: [new RTCTransport({ signalUrl: 'ws://signal:9000', ticket: hostTicket })],
    authMiddleware: new ArenaAuthMiddleware(),
})
rivalis.rooms.define('ttt', TttRoom)
rivalis.rooms.create('ttt', 'game-1')

Swap to WSTransport (or add both to the array) without touching TttRoom.

Quick start — Node peer

import { RTCClient } from '@rivalis/node'

const client = new RTCClient('ws://signal:9000')
client.on('client:connect', () => console.log('connected'))
client.on('ttt:state', (payload) => render(decode(payload)))
client.connect(ticket)   // ticket validated by the host's AuthMiddleware
client.send('place', encode({ index: 4 }))

The RTCClient API is identical to @rivalis/browser's RTCClient — same connect, disconnect, send, on/once/off, and the same ClientEvent set.

API

RTCTransport

import { RTCTransport } from '@rivalis/node'

class RTCTransport extends Transport {
    constructor(options: RTCTransportOptions)
    get sockets(): number          // number of open data channels post-handshake
    dispose(): Promise<void>       // closes all channels, peer connections, and the signal client
}

type RTCTransportOptions = {
    signalUrl: string              // WebSocket URL of @rivalis/signal
    ticket: string                 // host's signaling ticket (identifies this process as the host)
    channelLabel?: string          // data channel label; default 'rivalis'
    maxFrameBytes?: number         // enforce a frame-size ceiling; default RTC_MAX_FRAME_BYTES (16 KiB)
    peerLimiter?: ConnectionLimiter  // optional per-peerId pre-admission gate (see Security)
    unreliableTopics?: ReadonlySet<string> | ((topic: string) => boolean)  // Phase 4 dual-channel
}

RTCTransport implements the same five-step Transport seam as WSTransportgrantAccess, inbound handleMessage, outbound on('message'), on('kick'), and handleClose. The host Room receives an Actor with no indication of which transport admitted it.

Ticket protocol: because RTCDataChannel has no equivalent of the WebSocket handshake header, the peer sends its game-room auth ticket as the first binary message on the data channel. RTCTransport reads that message, calls grantAccess(ticket, { kind:'webrtc', remoteId: peerId }), then switches to normal game-frame forwarding.

RTCClient

import { RTCClient } from '@rivalis/node'

class RTCClient<TTopics extends string = string> extends Client<TTopics> {
    constructor(signalUrl: string, options?: RTCClientOptions)
    get connected(): boolean
    connect(ticket?: string): void
    disconnect(): void
    send(topic: string, payload?: Uint8Array | string): void
    // on / once / off — same ClientEvent taxonomy as WSClient
}

type RTCClientOptions = {
    reconnect?: boolean | RTCClientReconnectOptions
    getTicket?: () => string | Promise<string>
    channelReliability?: ChannelReliability    // default { ordered: true }
    dualChannel?: boolean                      // Phase 4; default false
    unreliableTopics?: ReadonlySet<string> | ((topic: string) => boolean)
    adapters?: Partial<RTCAdapters>            // override createPeerConnection for testing
}

Events — the full ClientEvent set:

| Event | Payload | When | |---|---|---| | client:connect | – | Data channel open (P2P handshake complete) | | client:disconnect | Uint8Array | Connection closed (data channel or peer connection) | | client:kicked | { code: number, reason: string } | Host sent a kick (4xxx close code) | | client:reconnecting | Uint8Array | Reconnect attempt scheduled | | client:reconnect_failed | – | maxAttempts exhausted or getTicket threw | | client:error | Error | Underlying transport error | | <your topic> | Uint8Array | Inbound frame from the host |

Reconnect is opt-in (reconnect: true) and reruns the full WebRTC negotiation per attempt. The same NO_RECONNECT_CODES gate as WSClient skips reconnect on terminal kicks (INVALID_TICKET, KICKED, ROOM_REJECTED).

SignalClient

Internal to RTCTransport and RTCClient; not typically used directly. Wraps the node WSClient for @rivalis/signal with typed signal:* topics.

Frame size and chunking

RTCDataChannel caps a single SCTP message to ~16 KiB across implementations. Frames larger than RTC_MAX_FRAME_BYTES = 16 384 bytes are automatically chunked before sending and reassembled before delivery using the internal topic __rivalis:chunk. Chunk/reassemble is transparent — Room.broadcast and client.on('topic', …) see full frames only.

Oversized frames (>255 chunks, i.e. >~4 MiB) are logged at warning level and dropped without sending — never silently truncated. Frames ≤ 16 KiB bypass chunking entirely with no allocation overhead.

For unreliable/unordered channels (Phase 4 dual-channel), frames larger than RTC_MAX_FRAME_BYTES are also dropped with a warning — chunk reassembly depends on ordered delivery and cannot work on an unordered channel.

WebRTC backend

The WebRTC backend is node-datachannel (libdatachannel, prebuilt native binary). RTCTransport and RTCClient are backend-agnostic — they program against the RTCPeerLike / RTCDataChannelLike adapter interfaces and never import a library name directly, so an alternative backend can be supplied through the createPeerConnection factory without touching the transport layer.

Security

DTLS encryption — no extra work

WebRTC data channels are DTLS-encrypted by default. Every byte of game traffic between a peer and the host is encrypted end-to-end at the transport layer — no configuration required, no certificates to provision on your side. DTLS is part of the WebRTC specification and is enforced by node-datachannel.

This applies to both legs:

  • Direct connection (STUN path, peer ↔ host) — DTLS-encrypted.
  • Relayed connection (TURN path via coturn) — DTLS-encrypted between the peer and the TURN server and between the TURN server and the host. The TURN relay sees encrypted bytes only; it cannot read game traffic.

There is no opt-out and nothing to enable. If you are auditing traffic between a RTCClient and an RTCTransport host, expect to see DTLS records, not plaintext.

Authentication

Peers are authenticated through RTCTransport via AuthMiddleware.authenticate — the same mechanism as WSTransport. The peer's game-room ticket is sent as the first binary message on the data channel (after the DTLS handshake), so the ticket itself is encrypted in transit. ConnectionContext { kind: 'webrtc', remoteId: peerId } is forwarded to authenticate, allowing transport-aware admission decisions.

Use constant-time comparison for ticket secrets — timingSafeCompare is exported from @rivalis/core.

Pre-admission throttle

RTC connections pass through two admission gates before AuthMiddleware runs:

| Hop | Where | Mechanism | |-----|-------|-----------| | 1 — signaling | @rivalis/signal WSTransport | ConnectionLimiter per remote IP | | 2 — game host | RTCTransport | optional peerLimiter?: ConnectionLimiter per signaling peerId |

The signaling gate is always active. The game-host gate is opt-in:

import { ConnectionLimiter } from '@rivalis/core'

new RTCTransport({
    signalUrl,
    ticket,
    peerLimiter: new ConnectionLimiter({ maxConnections: 5, windowMs: 60_000 }),
})

When the limiter returns false, the channel is closed with CloseCode.RATE_LIMITED before AuthMiddleware is ever invoked.

Rate limiting

Game-traffic rate limiting runs inside TLayer.handleMessage using the RateLimiter configured on the Rivalis instance (TokenBucketRateLimiter, default 30 tokens / 30 per second). This applies to WebRTC peers automatically — no transport-specific work.

Node host vs browser host

A Node host (RTCTransport in a Node process) runs in a trusted, controlled environment. Peers cannot inspect or tamper with its in-memory room state. Use it for competitive or authoritative games.

A browser host (Phase 3, RTCTransport from @rivalis/browser) runs inside the same JavaScript environment as every other peer. Suited for casual or co-operative play only. See browser/README.md §"Browser-as-host (Phase 3): trust note" for details.

License

MIT