symple

symple

Connect browser and native peers with presence, messaging and WebRTC signalling. Symple supplies the JSON protocol, peer addressing and call lifecycle.

A browser wants to call a camera running in a C++ process. Before it can receive video, it has to find the camera, know that it is online, reach the right device and negotiate the connection.

Symple handles that conversation. Presence, messages and the WebRTC call lifecycle use typed JSON over WebSocket. An SDP offer has a type and a destination, just like a chat message or an application event; it can use the same routing.

The Node.js server and browser clients speak the same wire format as the native server and client in icey. A browser can call a C++ peer without an application-specific translation service between them.

Presence, messages, then media

On connection, a peer announces itself and builds a roster from presence messages. The application can address a user or one particular connection. A call then follows the same channel through initiation, acceptance, SDP and ICE exchange.

Once WebRTC establishes a path, media travels between peers or through TURN. Symple keeps carrying the application conversation: end the call, report a detection, update a device’s state.

For an application that only needs messaging, symple-client works without the player package. A Rails background job can also inject an event through Redis to a connected browser.

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) -----------> | auth (Redis optional)       |
    |<-- 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) ============> |
    |                               |                              |
    |   SERVER-SIDE PUSH            |                              |
    |                          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 client’s roster from peer responses. 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. That handles candidates arriving while the remote description is still being applied.

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 streaming works: Symple carries the signalling, icey carries the media, and the protocol between them does not need translating.

Redis and server-side push

The Node server keeps connected peers and rooms in memory. Redis is optional: it supplies token-session lookup and a symple:broadcast channel for messages injected by a backend such as the Ruby client. Each subscribed server can deliver an injected message to its own connected peers.

That is useful for pushing an application event to browsers across several server processes. Ordinary client-to-client routing still uses the receiving server’s local peer and room maps. The current implementation does not provide automatic routing from a peer on Server A to a peer on Server B.

Session data lives at symple:session:<token> with configurable expiry. Authentication, message routing and media relay remain separate deployment concerns.

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 Redis broadcast channel reaches subscribed servers, which route the injected message to their local peers.

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 and an event bus, without 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 Server 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

The to field selects three routing patterns:

  • 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. Applications can disable that behaviour and assign rooms during authentication. The current server handles membership and delivery within its own process; applications must define and verify the authorization policy for each supported destination.

Getting started

git clone https://github.com/nilstate/symple-server.git
cd symple-server
npm install
npm start
import SympleClient from 'symple-client'

const client = new SympleClient({
  url: 'ws://localhost:4500',
  token: 'your-auth-token',
  peer: { user: 'my-app', 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))
client.connect()

For a media application, add symple-player in the browser or icey on the native side. The Symple server keeps carrying the presence and call messages while the media path is established between the peers or through TURN.