symple

symple

Real-time messaging and presence protocol. JSON-based, WebSocket transport, designed as signalling backbone for WebRTC applications.

Node.jsC++20WebRTCWebSocketRedis

Symple is a real-time messaging and presence protocol. JSON-based, designed as the signalling backbone for WebRTC applications. Presence tracking, scoped messaging, peer-to-peer video, horizontal scaling via Redis; all in one protocol.

Two implementations exist: the Node.js server and browser clients documented here, and a native C++ server and client inside icey’s symple module. Same wire format, same call lifecycle, different runtimes. A C++ Symple server can signal a browser running symple-player, or vice versa; the protocol doesn’t care what’s on either end.

Protocol design

Every message is JSON with a consistent structure: type, from, to, id, data. Types are message, presence, command, event, with an optional subtype for specialization (call:init, call:offer, etc.).

Peers are addressed as user|id; the user field alone targets all sockets for that user (multi-device), add the socket id to target a specific connection. One addressing scheme covers broadcast, room-cast, user-cast, and device-specific routing.

  CLIENT                          SERVER                         CLIENT
    |                               |                              |
    +-- connect(auth) -----------> | validate token (Redis)       |
    |<-- presence probe ----------> | --> broadcast -------------> |
    |<-- presence response <------- | <-- broadcast <------------- |
    |                               |                              |
    |   MESSAGING                   |                              |
    |-- message(to: room) -------> |  --> room broadcast -------> |
    |-- message(to: user|id) ----> | --> direct route ----------> |
    |                               |                              |
    |   SIGNALLING (via messages)   |                              |
    |-- call:init ----------------> | --> route -----------------> |
    |<- call:accept <-------------- | <-- route <----------------- |
    |-- call:offer (SDP) ---------> | --> route -----------------> |
    |<- call:answer (SDP) <-------- | <-- route <----------------- |
    |<- call:candidate (ICE) <----> | <--> route <-->-----------> |
    |                               |                              |
    |<=========== WebRTC P2P (media bypasses server) ============> |
    |                               |                              |
    |   HORIZONTAL SCALING          |                              |
    |                          Redis pub/sub                       |
    |                          +----+----+                         |
    |                       Server 1  Server 2                     |
    |                                    |                         |
    |                          Ruby client (direct                 |
    |                          Redis inject, no WS)                |

Presence

Presence is just messages with type: 'presence'. Connect broadcasts online: true, disconnect broadcasts online: false. Same routing, same format.

New clients send a presence probe on connect. Remote peers respond with their current state (without the probe flag, preventing loops). This bootstraps the roster without server-side state management; peers learn about each other directly. The client-side Roster tracks peers in memory and fires events on changes (addPeer, removePeer).

WebRTC signalling

The full call lifecycle runs as message subtypes layered on regular messaging:

SubtypeDirectionPurpose
call:initCaller to CalleeInitiate
call:acceptCallee to CallerAccept
call:offerCaller to CalleeSDP offer
call:answerCallee to CallerSDP answer
call:candidateBothTrickle ICE
call:hangupEitherEnd call

The CallManager wires this together. It maps Symple message events to WebRTCPlayer methods and player events back to Symple messages. The WebRTCPlayer doesn’t know about Symple, the CallManager doesn’t know about ICE internals. The signalling transport is pluggable.

ICE candidates arriving before the remote description is set get buffered and flushed when it lands; handling the race condition that most WebRTC implementations get wrong.

icey’s C++ implementation follows the same call lifecycle. The native symple module handles auth, presence, rooms, and the full call:* subtype sequence over WebSocket, feeding directly into icey’s WebRTC peer sessions and PacketStream pipeline. That’s how you get 150-line camera-to-browser streaming; Symple handles the signalling, icey handles the media.

Horizontal scaling

Multiple server instances behind a load balancer, connected via Redis pub/sub. A message from a client on Server A to a user on Server B publishes to Redis, the adapter on B picks it up and delivers. Socket IDs are globally unique across instances, so user|id addressing works seamlessly.

Session data lives in Redis at symple:session:<token> with configurable TTL. Token auth works identically regardless of which server the client connects to.

The Ruby client

Production Rails apps need server-side push; background jobs, model callbacks, event triggers. The Ruby client bypasses HTTP entirely. It encodes Symple-compatible packets and publishes them directly to Redis. No WebSocket connection from Ruby. No round-trip. Rails model callback fires, Redis publish happens, message appears on the client. The emit: true flag tells the adapter to rebroadcast across all server instances.

Also handles session lifecycle; token creation on login, TTL extension on activity, cleanup on logout; with ActiveRecord hooks for automatic sync.

Client packages

Split into two with a clear boundary:

symple-client - pure messaging: connection management, routing, presence roster, rooms, event bus. 9KB. No media dependencies.

symple-player - media engines on top: WebRTCPlayer (two-way video/audio), MJPEGPlayer, WebcamPlayer, and CallManager. Engines register with a preference score and capability check; the app queries Media.preferredEngine() to pick the best option for the platform and falls back gracefully.

icey symple module - native C++ server and client. Full protocol implementation over WebSocket with auth, presence, rooms, and call signalling. Runs on libuv alongside the rest of the icey stack. icey-cli is the reference application; a single C++ binary that bundles a Symple server, TURN relay, WebRTC media pipeline, and web UI. Browser clients connect via symple-client and symple-player, the C++ server handles signalling and media, and the same Symple message channel carries application-level events like vision detections and speech activity alongside call negotiation.

Routing

Three patterns cover every case:

  • No to field - broadcast to all joined rooms
  • to is a string ("user|id") - direct to a specific peer or user
  • to is an array (["room1", "room2"]) - multicast to named rooms

Dynamic rooms let clients join and leave on the fly. The server handles room mechanics, Redis adapter ensures it works across server instances.

Getting started

npm install symple-server
node server.js
import { SympleClient } from 'symple-client'

const client = new SympleClient({
  url: 'http://localhost:4500',
  token: 'your-auth-token',
  peer: { name: 'My App', group: 'public' }
})

client.on('connect', () => console.log('Connected'))
client.on('message', (m) => console.log('Message:', m))
client.on('presence', (p) => console.log('Presence:', p))