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/signal

v8.0.0

Published

Signaling server for Rivalis P2P: WebRTC SDP/ICE relay and TURN credential issuance

Readme

@rivalis/signal

Signaling server for Rivalis P2P: WebRTC SDP/ICE relay and TURN credential issuance.

@rivalis/signal is a normal Rivalis app — a SignalRoom that relays SDP offers/answers and ICE candidates between peers, plus an IceConfig that mints short-lived TURN credentials. Game traffic never passes through signaling; after the WebRTC channel opens the signal server is idle for that session.

peer (RTCClient)      @rivalis/signal (SignalRoom)      host (RTCTransport)
  │  WS connect + ticket ──────────────►│                       │
  │◄── signal:welcome {hostId, iceServers} ─────────────────────│
  │  signal:offer {to:host} ───────────►│─── getActor → send ──►│
  │◄────────── relay ───────────────────│◄── signal:answer ──────│
  │  signal:ice ⇄ (trickle, both ways via SignalRoom)            │
  │═══════════ DataChannel OPEN (direct or via TURN relay) ══════│
  │                                     │   (signal is idle from here)

Install

npm install @rivalis/signal

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"

Quick start

import { SignalServer } from '@rivalis/signal'

const server = new SignalServer({
    port: 9000,
    secrets: [process.env.SIGNAL_SECRET!],
})

// server.rooms is a RoomManager — create additional sessions:
// server.rooms.create('signal', 'session-42')

Shut down gracefully:

process.on('SIGTERM', () => server.shutdown({ timeoutMs: 10_000 }))

API

SignalServer

class SignalServer {
    constructor(options: SignalServerOptions)
    readonly rooms: RoomManager<null>
    shutdown(opts?: { timeoutMs?: number }): Promise<void>
}

type SignalServerOptions = {
    port?: number            // required when server is not provided
    server?: http.Server     // attach to an existing HTTP server
    secrets: string[]        // ticket secrets; multiple = rotation support
    rateLimiter?: TokenBucketOptions
    allowedOrigins?: AllowedOrigins
}

IceConfig

Issues ephemeral TURN credentials following coturn's static-auth-secret REST scheme:

username   = "<unixExpiry>:<peerId>"
credential = base64(HMAC_SHA1(ICE_TURN_SECRET, username))
class IceConfig {
    constructor(options: IceConfigOptions)
    static fromEnv(): IceConfig  // reads ICE_TURN_URLS, ICE_TURN_SECRET, ICE_STUN_URLS, ICE_TTL
    issueFor(peerId: string): string  // returns JSON-encoded RTCIceServer[]
}

SignalRoom calls IceConfig.fromEnv() and sends credentials to each peer inside signal:welcome. Override SignalRoom.iceConfig in a subclass to inject a custom instance.

SignalAuthMiddleware

Validates the ticket format <roomId>:<secret>. All configured secrets are accepted, enabling zero-downtime rotation. Comparison is constant-time (SHA-256 + timingSafeEqual).

Wire codec

import {
    encodeWelcome, decodeWelcome,
    encodeOffer, decodeOffer,
    encodeAnswer, decodeAnswer,
    encodeIceCandidate, decodeIceCandidate,
} from '@rivalis/signal'

Topics: signal:welcome, signal:offer, signal:answer, signal:ice, signal:host_gone.

Environment variables

| Variable | Default | Description | |---|---|---| | ICE_TURN_URLS | (none) | Comma-separated TURN URLs, e.g. turn:turn.example.com:3478,turns:turn.example.com:5349 | | ICE_TURN_SECRET | (none) | HMAC shared secret — must match static-auth-secret in turnserver.conf | | ICE_STUN_URLS | (none) | Comma-separated STUN-only URLs (no credentials) | | ICE_TTL | 86400 | Credential TTL in seconds (default 24 h) | | RIVALIS_STUN_DEV | (off) | Dev-only pure-JS STUN responder (Phase 4, not yet implemented) |

When ICE_TURN_URLS or ICE_TURN_SECRET is absent, IceConfig.issueFor() returns an empty array and no TURN relay is offered. Peers must fall back to direct STUN. Suitable for local development; requires coturn in production to cross NAT.

Deployment: coturn as a TURN relay sidecar

@rivalis/signal issues TURN credentials but does not implement a TURN relay — that is coturn's job. See p2p.md §4.3 and p2p.md §14 for the rationale (STUN/TURN are UDP; reimplementing them in Node would compete with game-loop CPU and is outside Node's idiomatic strengths).

Architecture

                  ┌─────────────────┐
   peers ──WS──►  │ @rivalis/signal │ (mints TURN creds via IceConfig)
                  └────────┬────────┘
                           │ shared ICE_TURN_SECRET
                  ┌────────▼────────┐
   peers ──UDP──► │ coturn (TURN)   │ (validates HMAC, relays UDP)
                  └─────────────────┘

Both processes use the same static-auth-secret. The signal server mints credentials; coturn validates them without any network call between the two.

Install coturn

# Debian / Ubuntu
apt-get install coturn

# macOS (dev/testing only)
brew install coturn

# Docker
docker run -d --network=host coturn/coturn

Configure coturn

A ready-to-use template is at coturn/turnserver.conf in this package. Copy it and replace the <PLACEHOLDER> values:

cp node_modules/@rivalis/signal/coturn/turnserver.conf /etc/turnserver.conf
$EDITOR /etc/turnserver.conf

Minimum required settings:

# Validates IceConfig-minted credentials. Must match ICE_TURN_SECRET.
use-auth-secret
static-auth-secret=<YOUR_SECRET>

# Realm — use your domain.
realm=turn.example.com

# Ports (see firewall section below).
listening-port=3478
tls-listening-port=5349
min-port=49152
max-port=65535

# Security: block relay to internal addresses.
fingerprint
no-loopback-peers
no-multicast-peers

Start coturn:

turnserver -c /etc/turnserver.conf
# or
systemctl start coturn

Ports and firewall

| Port | Protocol | Direction | Purpose | |------|----------|-----------|---------| | 3478 | UDP | inbound | STUN binding requests and TURN allocation | | 3478 | TCP | inbound | TURN over TCP (fallback for UDP-blocked clients) | | 5349 | TCP | inbound | TURNS — TURN over TLS (recommended for production) | | 49152–65535 | UDP | inbound | TURN relay ports (allocated per session) |

Open all of these in your firewall or security group. The relay port range must be reachable by every peer that may use the relay path.

TLS (TURNS)

TURNS (turns: scheme, port 5349) is strongly recommended in production — it works through HTTPS-only proxies and corporate firewalls where plain UDP/TCP TURN is blocked.

Obtain a certificate (Let's Encrypt example):

certbot certonly --standalone -d turn.example.com

Add to turnserver.conf:

tls-listening-port=5349
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem

Set both schemes in ICE_TURN_URLS so clients can fall back:

ICE_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349

Auto-renew: certbot's renewal hook should restart coturn after cert renewal:

# /etc/letsencrypt/renewal-hooks/deploy/coturn.sh
#!/bin/sh
systemctl restart coturn

Secret rotation (zero-downtime)

Both the signal server (ICE_TURN_SECRET) and coturn (static-auth-secret) share one secret. Rotation requires a brief overlap window:

  1. Generate a new secret.

    openssl rand -hex 32
  2. Update the signal server first. Set ICE_TURN_SECRET to the new secret and restart (or hot-reload) the signal process. New credentials are minted with the new secret from this point.

  3. Wait for old credentials to expire. The maximum window is ICE_TTL (default 24 h). Peers whose credentials were minted before step 2 will continue to work on coturn (which still has the old static-auth-secret) until their credentials expire.

  4. Update coturn. Replace static-auth-secret in turnserver.conf with the new secret and restart:

    systemctl restart coturn

    From this point coturn accepts only credentials minted with the new secret.

For a rolling coturn deployment (multiple instances behind a load balancer), restart each instance one at a time during step 4. The TTL overlap keeps the other instances accepting both old and new creds via the unchanged static-auth-secret until each restarts.

Never log or expose ICE_TURN_SECRET. Clients receive only the derived username/credential pair, which is time-limited and peer-scoped.

Environment example (production)

# signal server environment
SIGNAL_SECRET=<strong-random-secret-for-ticket-auth>
ICE_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349
ICE_TURN_SECRET=<strong-random-secret-shared-with-coturn>
ICE_STUN_URLS=stun:turn.example.com:3478
ICE_TTL=86400

# coturn turnserver.conf (matching ICE_TURN_SECRET above)
use-auth-secret
static-auth-secret=<strong-random-secret-shared-with-coturn>

Docker Compose example

services:
  signal:
    image: your-org/signal:latest
    environment:
      SIGNAL_SECRET: "${SIGNAL_SECRET}"
      ICE_TURN_URLS: "turn:turn.example.com:3478,turns:turn.example.com:5349"
      ICE_TURN_SECRET: "${ICE_TURN_SECRET}"
      ICE_STUN_URLS: "stun:turn.example.com:3478"
    ports:
      - "9000:9000"

  coturn:
    image: coturn/coturn:latest
    network_mode: host   # required for relay port allocation
    volumes:
      - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    command: -c /etc/coturn/turnserver.conf

network_mode: host is required because coturn allocates relay UDP ports dynamically from min-portmax-port. Port mapping every relay port individually is impractical; host networking is the standard approach for containerised coturn.

Browser peers and TURN

When a browser RTCClient connects to @rivalis/signal, the signal:welcome message includes iceServers — a JSON array of RTCIceServer objects with short-lived TURN credentials minted by IceConfig. RTCClient passes this array to RTCPeerConnection automatically. No browser-side configuration is needed to enable TURN — provision coturn and set the environment variables above, and browser peers will traverse NAT via the relay without any extra client code.

import { RTCClient } from '@rivalis/browser'

// ICE servers (including TURN creds) are delivered in signal:welcome — nothing else needed.
const client = new RTCClient('wss://signal.example.com:9000')
client.connect(ticket)   // ticket = '<roomId>:<secret>'
client.on('client:connect', () => console.log('P2P connected (direct or via TURN)'))
client.on('ttt:state', (payload) => render(decode(payload)))
client.send('place', encode({ index: 4 }))

Production requirements for browser peers

  • wss:// for the signal URL. Browsers block ws:// connections from HTTPS pages. Deploy @rivalis/signal behind TLS and use wss:// in the RTCClient constructor.

  • turns: alongside turn:. Set both schemes in ICE_TURN_URLS. The turns: URL works through HTTPS proxies and corporate firewalls where plain UDP/TCP TURN is blocked; turn: is the direct fallback for less-restrictive networks:

    ICE_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349

    See TLS (TURNS) above for the coturn certificate setup.

Forced-relay testing for browsers

To verify that the TURN relay is reachable and credentials are correct, pass an adapters override that sets iceTransportPolicy: 'relay'. This forces the browser to ignore direct and STUN paths and connect only through the relay — if the channel opens, coturn is wired correctly.

import { RTCClient } from '@rivalis/browser'

const client = new RTCClient('wss://signal.example.com:9000', {
    adapters: {
        createPeerConnection: (config) =>
            new RTCPeerConnection({ ...config, iceTransportPolicy: 'relay' }),
    },
})
client.connect(ticket)

Revert the adapters override for production — iceTransportPolicy defaults to 'all' (use direct/STUN first, fall back to TURN only when needed).

Provision coturn and configure ICE_TURN_URLS / ICE_TURN_SECRET first — see Install coturn and Configure coturn above. The template at coturn/turnserver.conf documents all required settings.

CI / forced-relay testing

To verify TURN relay end-to-end in CI, run a coturn container with iceTransportPolicy:'relay' on the client. See task 077-node-low-nat-turn-relay-test.md for the full test setup.

Security

  • Credentials are ephemeral. IceConfig.issueFor() mints short-lived creds (username = <unixExpiry>:<peerId>). coturn rejects creds past unixExpiry. The shared secret never leaves the server.
  • Signaling is authenticated. The WS leg is gated by SignalAuthMiddleware with constant-time secret comparison. Configure allowedOrigins to mitigate CSRF.
  • No JS TURN relay. Production relay is coturn — never reimplemented in Node. See CHANGELOG.md D6 and p2p.md §4.3 for the full rationale.
  • DTLS by default. WebRTC data channels are DTLS-encrypted — game traffic is encrypted end-to-end with no extra work.
  • RIVALIS_STUN_DEV=true enables a dev-only pure-JS STUN responder (Phase 4, not yet implemented). It handles binding requests only, has no relay capability, and must never be used on a production port.

License

MIT