symple

symple

JSON over WebSocket. Presence, messaging, and WebRTC call signalling share the same wire format.

Most WebRTC signalling protocols invent a new wire format for what is structurally a message-passing problem. The SDP offer, the ICE candidate, the accept, the answer, the hangup; they are all payloads with a type and a destination. Symple treats them that way: presence, peer-to-peer messaging, and the full WebRTC call lifecycle run as typed JSON messages over WebSocket.

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 does not care what is 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 specialisation (call:init, call:offer, etc.).

Peers are addressed as user|id. The user field alone targets all sockets for that user (multi-device); appending the socket id targets one 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 as everything else on the wire.

New clients send a presence probe on connect. Remote peers respond with their current state (without the probe flag, preventing loops), bootstrapping the roster without server-side state. 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. WebRTCPlayer does not know about Symple; CallManager does not 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. This is the race condition 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 is how camera-to-browser in 150 lines works: Symple carries the signalling, icey carries the media, and the protocol between them does not need translating.

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 the same regardless of which server handles either end.

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 skips the WebSocket entirely. It encodes Symple-compatible packets and publishes them directly to Redis; a Rails model callback fires, the adapter on each connected server picks the message up, and it appears on the client. The emit: true flag tells the adapter to rebroadcast across all server instances.

It also handles session lifecycle through ActiveRecord hooks: tokens created on login, TTLs extended on activity, sessions cleaned up on logout.

Client packages

Split into two with a clear boundary:

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

symple-player layers 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’s symple module is the native C++ implementation: full protocol over WebSocket with auth, presence, rooms, and call signalling, running on libuv alongside the rest of the icey stack. icey-cli is the reference application: one C++ binary bundling a Symple server, TURN relay, WebRTC media pipeline, and web UI. Browser clients connect via symple-client and symple-player; 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 broadcasts to all joined rooms.
  • to as a string ("user|id") routes to a specific peer or user.
  • to as an array (["room1", "room2"]) multicasts to named rooms.

Dynamic rooms let clients join and leave on the fly. The server handles room mechanics; the 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))

Same envelope for presence, chat, and the call lifecycle. The protocol does not change shape between them.