# Dendri — full documentation > Open-source, self-hostable WebRTC signaling stack for realtime collaboration apps. > This file inlines every page of https://dendri.dev/docs/ as markdown. > Index: https://dendri.dev/llms.txt --- # Introduction Dendri adds realtime collaboration to any web app (presence, live cursors, chat, video, and CRDT document sync) using peer-to-peer WebRTC instead of a message broker you pay per event. ## What is Dendri? Dendri is a small signaling service plus a TypeScript SDK. The server's only job is to introduce peers to each other and broker the WebRTC handshake. Once two browsers are connected, data flows *directly between them*: the server is out of the data path, which is why there is no per-message billing and no realtime backend for you to scale. The project ships as three parts: | Package | Version | License | What it is | | --- | --- | --- | --- | | `@afterrealism/dendri-client` | 2.6.x | Apache-2.0 | Framework-neutral browser/Node SDK: rooms, presence, topics, RPC, media calls, and adapters for React, Vue, and Svelte. | | `@afterrealism/dendri-y` | 0.1.x | Apache-2.0 | Yjs CRDT provider that syncs a `Y.Doc` (and awareness) over a Dendri room. | | `dendri-server` | Rust | AGPL-3.0 | Axum/Tokio signaling server with WebSocket, SSE, and long-poll transports, TURN credential minting, and an optional encrypted relay. | > **Note:** **Bring your own endpoint.** The SDK never connects anywhere by default. Every app passes an explicit server URL: your self-hosted instance or the hosted tier. If the URL is missing, connection fails loudly instead of silently phoning home. ## How it works When your app calls `join("room-name")`, the SDK connects to the signaling server, discovers the other peers in the room, and negotiates direct WebRTC connections with them. Rooms use a **star topology**: the first peer to join becomes the *host*, later peers connect to the host, and if the host disconnects a deterministic election promotes a new one (host migration), no coordination service required. Real networks are hostile, so connectivity degrades through four tiers automatically: | Tier | Path | When it's used | | --- | --- | --- | | 1 | Direct P2P | Same network, or open NAT. Lowest latency, zero server involvement. | | 2 | STUN | Peers behind NAT discover their public addresses and hole-punch. | | 3 | TURN | Symmetric NAT or UDP blocked; traffic relays through a TURN server (e.g. coturn). | | 4 | TLS relay | Everything else (strict VPNs, corporate proxies); messages relay through the signaling server, end-to-end encrypted with ECDH + AES-256-GCM so the server can't read them. | The signaling connection itself also falls back: WebSocket first, then Server-Sent Events, then HTTP long-poll, so the handshake works even behind proxies that kill WebSockets. ## Core concepts ### Peers A `Dendri` instance is one peer: a stable identity (peer ID) plus a signaling connection. Peers open **data connections** to exchange arbitrary payloads and **media connections** for audio/video. You rarely manage peers by hand (rooms and the store do it for you), but the low-level API is always available. ### Rooms A `Room` groups peers under a shared name and manages the star topology, peer discovery, reconnection, and host migration. Everything higher-level (presence, topics, RPC) hangs off a room. ### Presence Each peer can publish a small state object ("Ana, editing slide 3, cursor at…") that is broadcast to the room and kept in sync. Presence answers "who's here and what are they doing" without you building any plumbing. ### Topics Pub/sub channels inside a room. Broadcast with a `topic` tag and only subscribers to that topic receive it, so chat messages, cursor updates, and game state can share one room without interfering. ### RPC Request/response over data channels. Register a named method on one peer, call it from another, and get a promise back, with timeouts and typed errors. Useful for host-authoritative logic in games and tools. ### The store `createDendriStore()` wraps a room in a framework-agnostic reactive store (`subscribe` / `getSnapshot`), with ready-made bindings for React, Vue, and Svelte. It's the recommended entry point for UI apps; see the [framework guides](https://dendri.dev/docs/frameworks/). ## Explore the docs - [Quickstart](https://dendri.dev/docs/quickstart/) — Run a server, install the SDK, and ship a working P2P chat in about ten minutes. - [Configuration](https://dendri.dev/docs/configuration/) — Every client option: URLs, transports, TURN, relay, JWT auth, and debugging. - [Framework guides](https://dendri.dev/docs/frameworks/) — Complete React, Vue, Svelte, and vanilla JS integrations with the official adapters. - [Building the examples](https://dendri.dev/docs/examples/) — Walkthroughs of chat, live cursors, presence, whiteboard, video, files, and a game. - [Collaborative editing](https://dendri.dev/docs/collaboration/) — Sync Yjs documents and awareness over a room with `@afterrealism/dendri-y`. - [API reference](https://dendri.dev/docs/api/) — Classes, methods, events, and types for the client SDK and the Yjs provider. - [Self-hosting](https://dendri.dev/docs/self-hosting/) — Run the open-source Rust server with Docker/Podman, TLS, TURN, and Redis. - [AI & agents](https://dendri.dev/docs/ai/) — llms.txt, markdown docs, and the MCP server for Claude Code, opencode, Cursor, and VS Code. ## Licensing at a glance The client SDKs (`dendri-client`, `dendri-y`) are **Apache-2.0**: embed them in anything, commercial or not. The signaling server is **AGPL-3.0**: run it freely, but if you offer it as a network service with modifications, you must publish those modifications. A commercial license is available from Afterrealism for closed-source hosted use. Details in [Self-hosting → Licensing](https://dendri.dev/docs/self-hosting/#licensing). Source: https://dendri.dev/docs/ --- # Quickstart From zero to a working peer-to-peer chat in four steps: get a server endpoint, install the SDK, wire up a room, and open two tabs. ## Prerequisites - **Node.js 18+** and a package manager (npm, pnpm, or bun). - A **Dendri server endpoint**: hosted or local (step 1 covers both). - A modern browser. WebRTC is supported everywhere Dendri runs. ## Step 1: Get a server endpoint The SDK always connects to an endpoint *you* provide. Pick one of two paths: ### Option A: Hosted tier Grab a plan on the [pricing page](https://dendri.dev/#pricing); your API key arrives by email and can be managed in the [dashboard](https://app.dendri.dev/). You'll connect with: ```ts title="hosted endpoint" const store = createDendriStore({ url: "wss://signal.dendri.dev", apiKey: "dk_your_key", }); ``` ### Option B: Local server (self-hosted) The open-source server is a single Rust binary. It uses Redis for state, so start both containers: ```bash title="terminal" # 1. Redis (state backend) podman run -d --name dendri-redis -p 6379:6379 docker.io/redis:7 # 2. The signaling server on port 9876 podman run -d --name dendri --network host \ ghcr.io/afterrealism/dendri-server:latest \ --host 127.0.0.1 --port 9876 \ --redis_url redis://127.0.0.1:6379 \ --enable_relay --allow_discovery ``` Or run it from source with Cargo (Redis still required, default `redis://127.0.0.1:6379`): ```bash title="terminal" git clone https://github.com/afterrealism/dendri-server cd dendri-server cargo run -- --host 127.0.0.1 --port 9876 --enable_relay --allow_discovery ``` Verify it's alive: `curl http://127.0.0.1:9876/health`. Full flags, TLS, and TURN are covered in [Self-hosting](https://dendri.dev/docs/self-hosting/). ## Step 2: Install the SDK ```bash title="terminal" npm install @afterrealism/dendri-client ``` Starting a project from scratch? A Vite vanilla template is the fastest sandbox: ```bash title="terminal" npm create vite@latest dendri-chat -- --template vanilla cd dendri-chat npm install @afterrealism/dendri-client npm run dev ``` ## Step 3: Build the chat Replace the generated `index.html` and `main.js` with the following. ```html title="index.html"

Dendri chat

Status: connecting… ·

``` ```js title="main.js" import { createDendriStore } from "@afterrealism/dendri-client"; // Point at YOUR endpoint. Hosted: url + apiKey. Local: the server from step 1. const store = createDendriStore({ url: "ws://127.0.0.1:9876", fetchTurnCredentials: true, // use the server's TURN credentials if configured enableRelay: true, // tier-4 encrypted relay fallback for hostile networks }); const status = document.querySelector("#status"); const peers = document.querySelector("#peers"); const log = document.querySelector("#log"); const form = document.querySelector("#form"); const input = document.querySelector("#input"); // 1. React to connection / peer changes. store.subscribe(() => { const s = store.getSnapshot(); status.textContent = s.connectionState; peers.textContent = `${s.peerCount} peer(s) — you are ${s.isHost ? "host" : "client"}`; }); // 2. Receive messages published on the "chat" topic. store.subscribe("chat", (data, peerId) => { append(peerId.slice(0, 6), data.text); }); // 3. Send messages to everyone in the room. form.addEventListener("submit", (event) => { event.preventDefault(); const text = input.value.trim(); if (!text) return; store.broadcast({ text }, { topic: "chat" }); append("me", text); input.value = ""; }); function append(from, text) { const li = document.createElement("li"); li.textContent = `${from}: ${text}`; log.appendChild(li); } // 4. Join the room. First tab becomes host; later tabs connect to it. store.join("quickstart-room"); ``` ## Step 4: Run it Start the dev server (`npm run dev`) and open `http://localhost:5173` in **two browser tabs**. Each tab is an independent peer. Type in one tab and the message appears in the other, delivered directly over a WebRTC data channel. ### What just happened? 1. Each tab connected to your signaling server and got a peer ID. 2. `join("quickstart-room")` made the first tab the **host**; the second tab discovered it and opened a direct data connection. 3. `broadcast(…, { topic: "chat" })` fanned the message out to every peer subscribed to `"chat"`. 4. If WebRTC had been blocked (VPN, strict NAT), delivery would have fallen back to TURN or the encrypted relay automatically. > **Tip:** **Close the tab, watch the host migrate.** Open three tabs, close the first one (the host), and watch the `isHost` flag flip in another tab: deterministic host migration with no server-side coordination. ## No build step? Use the CDN global The package ships a browser global (IIFE) build for script-tag workflows: ```html title="index.html" ``` The global build exposes `Dendri` and `util` on `window.dendri`. Prefer npm imports for framework apps. ## Next steps - [Configuration](https://dendri.dev/docs/configuration/) — All client options: transports, TURN, relay, auth, debugging. - [Framework guides](https://dendri.dev/docs/frameworks/) — The same app in React, Vue, and Svelte with the official adapters. - [Building the examples](https://dendri.dev/docs/examples/) — Cursors, presence, whiteboard, video, file transfer, and a game. - [Self-hosting](https://dendri.dev/docs/self-hosting/) — Production deployment: TLS, TURN, Redis, and scaling. Source: https://dendri.dev/docs/quickstart/ --- # Configuration One options object controls everything: where the client connects, which signaling transport it uses, how it authenticates, and how aggressively it falls back when networks misbehave. ## Passing options The same options object is accepted by `new Dendri(options)`, `new Dendri(id, options)`, and `createDendriStore(options)`. The simplest form is a single `url`: ```ts title="app.ts" import { Dendri, createDendriStore } from "@afterrealism/dendri-client"; // Store (recommended for UI apps) const store = createDendriStore({ url: "wss://signal.example.com" }); // Low-level peer, with an explicit peer ID const peer = new Dendri("alice-laptop", { url: "wss://signal.example.com" }); ``` `url` is shorthand for `host` / `port` / `secure` / `path`. If you pass any of those explicitly, the explicit value wins over the url-derived one. ## Option reference | Option | Type | Default | Description | | --- | --- | --- | --- | | `url` | string | — | Full server URL, e.g. `wss://signal.example.com` or `http://127.0.0.1:9876`. Shorthand for host/port/secure/path. | | `host` | string | — | Server hostname, e.g. `signal.example.com`. | | `port` | number | — | Server port, e.g. `443` or `9876`. | | `path` | string | `"/"` | URL path prefix; must match the server's `--path`. | | `secure` | boolean | — | `true` for `wss/https`, `false` for `ws/http`. | | `apiKey` | string | — | API key for hosted / multi-tenant deployments. Sent with every signaling and REST request. | | `key` | string | `"dendri"` | Connection key namespace; must match the server's `--key`. | | `jwt` | string | — | JWT for authenticated connections (server started with `--jwt_secret`). | | `token` | string | random | Session token used to authenticate reconnects for the same peer ID. | | `signalingTransport` | `"websocket" \| "sse" \| "polling" \| "auto"` | `"websocket"` | Signaling transport. `"auto"` tries WebSocket → SSE → long-poll. | | `fetchTurnCredentials` | boolean | `false` | Auto-fetch ephemeral TURN credentials from the server's `/turn-credentials` endpoint. | | `enableRelay` | boolean | `false` | Enable the tier-4 encrypted WebSocket relay when WebRTC is unavailable. | | `config` | `RTCConfiguration` | — | Custom ICE configuration passed to every `RTCPeerConnection` (your own STUN/TURN servers). | | `validateMetadata` | `(meta: unknown) => boolean` | — | Validate incoming connection metadata before accepting a connection. | | `referrerPolicy` | `ReferrerPolicy` | — | Referrer policy for the SDK's HTTP requests. | | `debug` | number | `0` | Log verbosity, `0` (silent) to `3` (verbose). | ## Configuring from environment variables Apps should never hardcode a server URL. The pattern used by every official example reads a single env var and derives the parts, so the same build runs against local, staging, and production servers: ```ts title="dendri-options.ts (Vite)" // .env.local → VITE_DENDRI_URL=https://signal.example.com // VITE_DENDRI_API_KEY=dk_your_key (hosted tier only) const serverUrl = new URL(import.meta.env.VITE_DENDRI_URL ?? "http://127.0.0.1:9876"); export const DENDRI_OPTIONS = { host: serverUrl.hostname, port: Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80), secure: serverUrl.protocol === "https:", path: serverUrl.pathname, apiKey: import.meta.env.VITE_DENDRI_API_KEY, fetchTurnCredentials: true, enableRelay: true, debug: 0, }; ``` > **Note:** **No hidden defaults.** If no URL is configured, fail fast with a clear error. The SDK will never fall back to a vendor-operated endpoint on its own. ## Signaling transports Signaling (the handshake channel) is separate from the data path. Dendri supports three transports against the same server, selected with `signalingTransport`: | Value | Mechanism | Use when | | --- | --- | --- | | `"websocket"` | Persistent WebSocket (default) | Normal networks. Lowest latency. | | `"sse"` | Server-Sent Events + HTTP POST | Proxies that terminate WebSockets. | | `"polling"` | HTTP long-poll | The most restrictive corporate networks. | | `"auto"` | WS → SSE → polling | You don't control your users' networks. Recommended for public apps. | ## Connectivity & fallback ### TURN credentials For peers behind symmetric NAT, WebRTC needs a TURN relay. If your server is configured with `--turn_secret`, set `fetchTurnCredentials: true` and the client automatically fetches short-lived credentials before connecting. Alternatively, pass your own ICE servers: ```ts title="custom ICE servers" const store = createDendriStore({ url: "wss://signal.example.com", config: { iceServers: [ { urls: "stun:stun.example.com:3478" }, { urls: "turn:turn.example.com:3478", username: "user", credential: "pass", }, ], }, }); ``` ### Encrypted TLS relay With `enableRelay: true`, room broadcasts also flow through the signaling server as a last-resort delivery path (tier 4), necessary on networks where ICE reports success but packets silently vanish (VPNs, UDP-blocked networks). Relayed payloads are end-to-end encrypted with ECDH-derived AES-256-GCM keys, so the server relays ciphertext it cannot read. Duplicate deliveries are de-duplicated on the receiving side. ## Connection lifecycle The client runs a connection state machine, exposed as `peer.connectionState` / `store.connectionState` and via the `connectionStateChanged` event: | State | Meaning | | --- | --- | | `initialized` | Created, not yet connected. | | `connecting` | Attempting to reach the signaling server. | | `connected` | Connected and ready. | | `disconnected` | Temporarily offline; auto-reconnect with exponential backoff and jitter. | | `suspended` | Extended offline (5+ consecutive failures); slower retry schedule. | | `closed` | Explicitly closed by your code. | | `failed` | Unrecoverable error. | After a successful reconnect the SDK re-sends room membership and presence automatically, so a server restart or a laptop lid-close doesn't leave peers in a ghost state. ## Room and store options `createDendriStore()` also accepts an advanced shape when you need room-level settings or want to inject a custom client class in tests: ```ts title="advanced store options" const store = createDendriStore({ dendriOptions: { url: "wss://signal.example.com", enableRelay: true }, roomOptions: { // How long to wait for a new host to become reachable // during host migration (default 10 000 ms). migrationTimeout: 5000, }, }); ``` ## Multi-tab behaviour When two tabs use the same `peer_id` against the same server: - **Same peer ID, same token**: treated as a reconnection. The server adopts the new socket; room memberships and queued messages survive. - **Same peer ID, different token**: rejected with `ID_TAKEN`, which prevents accidental or malicious ID hijacking. For multi-tab apps, either coordinate a single connection with `BroadcastChannel`, or let each tab be its own peer and communicate through a room. ## Debugging Set `debug: 3` for verbose logs of signaling, ICE negotiation, and transport fallbacks. `peer.diagnostics()` returns a snapshot of connection internals suitable for bug reports. Source: https://dendri.dev/docs/configuration/ --- # Framework guides The SDK core is plain TypeScript with zero framework coupling. Official adapters ship as subpath exports for React, Vue, and Svelte — each one is a thin bridge from the store's `subscribe`/`getSnapshot` contract to the framework's reactivity system. ## The store contract Every guide below starts from the same primitive: `createDendriStore(options)` returns a `DendriStore` whose reactive snapshot contains: | Field | Type | Meaning | | --- | --- | --- | | `connectionState` | `ConnectionState` | `"connecting"`, `"connected"`, `"disconnected"`, … | | `myPeerId` | `string \| null` | This peer's ID once joined. | | `isHost` | `boolean` | Whether this peer is the room host. | | `hostId` | `string \| null` | The current host's peer ID. | | `peers` | `readonly string[]` | Remote peer IDs in the room. | | `peerCount` | `number` | Number of remote peers. | | `presences` | `ReadonlyMap` | Presence data per remote peer. | All guides share this options module (see [Configuration](https://dendri.dev/docs/configuration/#env-pattern)): ```js title="src/dendri-options.js" const serverUrl = new URL(import.meta.env.VITE_DENDRI_URL ?? "http://127.0.0.1:9876"); export const DENDRI_OPTIONS = { host: serverUrl.hostname, port: Number(serverUrl.port) || (serverUrl.protocol === "https:" ? 443 : 80), secure: serverUrl.protocol === "https:", path: serverUrl.pathname, apiKey: import.meta.env.VITE_DENDRI_API_KEY, fetchTurnCredentials: true, enableRelay: true, }; ``` ## React **Live demo:** [react-basic.dendri.dev](https://react-basic.dendri.dev/) (source: [react-basic](https://github.com/afterrealism/dendri-examples/tree/main/react-basic)). The React adapter is a hook built on `useSyncExternalStore`, so components re-render exactly when the snapshot changes — concurrent-mode safe, no manual subscriptions. React 18+ is an optional peer dependency. ```bash title="terminal" npm create vite@latest dendri-react -- --template react cd dendri-react npm install @afterrealism/dendri-client ``` ```jsx title="src/Chat.jsx" import { createDendriStore } from "@afterrealism/dendri-client"; import { useDendriStore } from "@afterrealism/dendri-client/react"; import { useEffect, useRef, useState } from "react"; import { DENDRI_OPTIONS } from "./dendri-options"; export default function Chat() { // Create the store once per component instance — never in render. const storeRef = useRef(null); if (storeRef.current === null) { storeRef.current = createDendriStore(DENDRI_OPTIONS); } const store = storeRef.current; // Reactive snapshot: re-renders on connection/peer/presence changes. const { connectionState, isHost, peerCount, myPeerId } = useDendriStore(store); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); useEffect(() => { // Receive messages published on the "chat" topic. const unsubscribe = store.subscribe("chat", (data, peerId) => { setMessages((prev) => [...prev, { from: peerId, text: data.text }]); }); store.join("react-room"); return () => { unsubscribe(); store.destroy(); // leaves the room and closes the connection }; }, [store]); function send() { const text = draft.trim(); if (!text) return; store.broadcast({ text }, { topic: "chat" }); setMessages((prev) => [...prev, { from: "me", text }]); setDraft(""); } return (

Dendri × React

{connectionState === "connected" ? "Connected" : "Connecting…"} ·{" "} {isHost ? "host" : "client"} · {peerCount} peer{peerCount === 1 ? "" : "s"}

setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} placeholder="Type a message…" />
); } ``` > **Note:** **Next.js:** importing the package is SSR-safe, but WebRTC and WebSockets are browser side effects. Mark the component `"use client"` and keep `join()` inside `useEffect` — exactly as above. ## Vue **Live demo:** [vue-basic.dendri.dev](https://vue-basic.dendri.dev/) (source: [vue-basic](https://github.com/afterrealism/dendri-examples/tree/main/vue-basic)). The Vue adapter returns a read-only `shallowRef` that tracks the snapshot and automatically unsubscribes when the component unmounts. Vue 3.3+ is an optional peer dependency. ```bash title="terminal" npm create vite@latest dendri-vue -- --template vue cd dendri-vue npm install @afterrealism/dendri-client ``` ```vue title="src/Chat.vue" ``` ## Svelte The Svelte adapter converts the store to the standard Svelte store contract, so `$snapshot` auto-subscription just works — in Svelte 4 and Svelte 5. This adapter has no peer dependency at all. ```bash title="terminal" npx sv create dendri-svelte cd dendri-svelte npm install @afterrealism/dendri-client ``` ```svelte title="src/routes/+page.svelte"

Dendri × Svelte

{$snapshot.connectionState === "connected" ? "Connected" : "Connecting…"} · {$snapshot.isHost ? "host" : "client"} · {$snapshot.peerCount} peers

e.key === "Enter" && send()} />
``` > **Tip:** **Svelte 5 runes:** the official Svelte examples wrap the store in a `.svelte.ts` module using `$state` and expose getters — see [the cursors demo](https://github.com/afterrealism/dendri-examples/tree/main/cursors) for the full pattern. ## Vanilla JavaScript No adapter needed — subscribe and re-render: ```js title="main.js" import { createDendriStore } from "@afterrealism/dendri-client"; import { DENDRI_OPTIONS } from "./dendri-options"; const store = createDendriStore(DENDRI_OPTIONS); store.subscribe(() => { const s = store.getSnapshot(); document.querySelector("#status").textContent = `${s.connectionState} · ${s.peerCount} peers · ${s.isHost ? "host" : "client"}`; }); store.subscribe("chat", (data, peerId) => { console.log(`${peerId}:`, data.text); }); store.join("vanilla-room"); ``` ## Any other framework The adapters are each ~20 lines. If your framework isn't listed (Solid, Angular, Lit, Preact, …), wrap the same two functions: ```ts title="the whole integration surface" // 1. Read the current state (immutable snapshot) const snapshot = store.getSnapshot(); // 2. Get notified when it changes (returns an unsubscribe function) const unsubscribe = store.subscribe(() => { render(store.getSnapshot()); }); ``` For example, in Solid: `createStore` + `onCleanup(store.subscribe(…))`; in Angular: a service exposing a `signal` updated inside the subscription. ## Cleanup rules (all frameworks) - Create the store **once** per app/component lifetime — never in a render body. - Call every unsubscribe function returned by `subscribe`/`onData`/`onPeerJoin` on teardown. - Call `store.destroy()` last — it leaves the room, closes connections, and removes all listeners. - In dev with hot-module reload, stale stores keep sockets open; destroying in the teardown hook (as in each example above) prevents ghost peers. Source: https://dendri.dev/docs/frameworks/ --- # Building the examples Twelve open-source demos ship in [dendri-examples](https://github.com/afterrealism/dendri-examples). This page walks through the core pattern behind each category so you can rebuild them (or build your own variant) in any framework. ## The demo catalogue | Demo | Live | Framework | Concepts demonstrated | | --- | --- | --- | --- | | [chat](https://github.com/afterrealism/dendri-examples/tree/main/chat) | [chat.dendri.dev](https://chat.dendri.dev/) | Svelte | Topics, store, broadcast | | [cursors](https://github.com/afterrealism/dendri-examples/tree/main/cursors) | [cursors.dendri.dev](https://cursors.dendri.dev/) | Svelte | Awareness, throttling, ephemeral state | | [presence](https://github.com/afterrealism/dendri-examples/tree/main/presence) | [presence.dendri.dev](https://presence.dendri.dev/) | Svelte | Presence API, peer lifecycle | | [whiteboard](https://github.com/afterrealism/dendri-examples/tree/main/whiteboard) | [whiteboard.dendri.dev](https://whiteboard.dendri.dev/) | Svelte | Topic streams, canvas, late-joiner sync | | [collaborative-edit](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-edit) | [collaborative-edit.dendri.dev](https://collaborative-edit.dendri.dev/) | Svelte | Yjs CRDT sync ([guide](https://dendri.dev/docs/collaboration/)) | | [collaborative-layers](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-layers) | [collaborative-layers.dendri.dev](https://collaborative-layers.dendri.dev/) | Svelte | Yjs, structured shared state | | [file-transfer](https://github.com/afterrealism/dendri-examples/tree/main/file-transfer) | [file-transfer.dendri.dev](https://file-transfer.dendri.dev/) | Svelte | Direct data connections, binary chunks | | [video-chat](https://github.com/afterrealism/dendri-examples/tree/main/video-chat) | [video-chat.dendri.dev](https://video-chat.dendri.dev/) | Svelte | Media calls, streams | | [game](https://github.com/afterrealism/dendri-examples/tree/main/game) | [game.dendri.dev](https://game.dendri.dev/) | Svelte | RPC, host authority, host migration | | [react-basic](https://github.com/afterrealism/dendri-examples/tree/main/react-basic) | [react-basic.dendri.dev](https://react-basic.dendri.dev/) | React | Store + React integration | | [vue-basic](https://github.com/afterrealism/dendri-examples/tree/main/vue-basic) | [vue-basic.dendri.dev](https://vue-basic.dendri.dev/) | Vue | Store + Vue integration | Every demo is a standalone Vite project: `cd` in, `pnpm install`, `pnpm dev`, and set `VITE_DENDRI_URL` (or `PUBLIC_DENDRI_URL` for the SvelteKit demos) to your server. ## Chat **Live demo:** [chat.dendri.dev](https://chat.dendri.dev/). Open it in two tabs to see the sync. *Concepts: topics, store.* Covered end-to-end in the [Quickstart](https://dendri.dev/docs/quickstart/#build-chat). The one-line summary: publish on a topic, subscribe to the topic, render. Everything else on this page is a variation of that loop. ## Live cursors **Live demo:** [cursors.dendri.dev](https://cursors.dendri.dev/). Open it in two tabs to see the sync. *Concepts: ephemeral state, throttling.* Cursor positions are high-frequency and disposable: never persist them, and throttle broadcasts so you don't flood slower peers. The simplest version uses a topic: ```js title="cursors.js" import { createDendriStore } from "@afterrealism/dendri-client"; import { DENDRI_OPTIONS } from "./dendri-options"; const store = createDendriStore(DENDRI_OPTIONS); const cursors = new Map(); // peerId → { x, y, name, color } const THROTTLE_MS = 50; // 20 updates/sec is plenty for cursors let lastSent = 0; window.addEventListener("pointermove", (event) => { const now = Date.now(); if (now - lastSent < THROTTLE_MS) return; lastSent = now; store.broadcast( { x: event.clientX / innerWidth, y: event.clientY / innerHeight }, { topic: "cursor" }, ); }); store.subscribe("cursor", (data, peerId) => { cursors.set(peerId, data); render(); }); // Remove cursors of peers that leave. store.onPeerLeave((peerId) => { cursors.delete(peerId); render(); }); function render() { for (const [peerId, c] of cursors) { const el = cursorElement(peerId); // create-or-get a positioned
el.style.transform = `translate(${c.x * innerWidth}px, ${c.y * innerHeight}px)`; } } store.join("cursor-room"); ``` - **Normalize coordinates** (0–1) so cursors land correctly on different screen sizes. - **Throttle at the source:** 30–60 ms feels live without wasting bandwidth. - The production [cursors demo](https://github.com/afterrealism/dendri-examples/tree/main/cursors) uses Yjs *awareness* instead of a raw topic, which gives automatic cleanup and last-write-wins semantics (see [Collaborative editing → Awareness](https://dendri.dev/docs/collaboration/#awareness)). ## Presence **Live demo:** [presence.dendri.dev](https://presence.dendri.dev/). Open it in two tabs to see the sync. *Concepts: presence API, peer lifecycle.* Presence is structured state about each peer (name, avatar, status, current document) synced by the room automatically. Unlike topics, the latest presence of every peer is always available in the snapshot, even for peers who published before you joined. ```js title="presence.js" const store = createDendriStore(DENDRI_OPTIONS); // Publish my presence. Call again any time it changes; peers see updates live. store.join("team-room"); store.setPresence({ name: "Ana", status: "editing", section: "intro" }); // React to everyone's presence in one place. store.subscribe(() => { const { presences, peers } = store.getSnapshot(); const people = peers.map((peerId) => ({ peerId, ...(presences.get(peerId) ?? { name: "Anonymous", status: "idle" }), })); renderAvatarRow(people); }); // Or listen for individual updates: store.onPresenceUpdate((peerId, data) => { console.log(`${peerId} is now`, data); }); ``` > **Tip:** **Presence vs topics:** use *presence* for "current state of a person" (replaces previous value, survives late joins) and *topics* for "events" (fire and forget, only live subscribers see them). ## Whiteboard **Live demo:** [whiteboard.dendri.dev](https://whiteboard.dendri.dev/). Open it in two tabs to see the sync. *Concepts: streaming a topic, late-joiner sync via RPC.* A shared canvas broadcasts stroke segments as they're drawn. The subtlety is history: a peer who joins late missed the strokes, so ask the host to replay them once via RPC. ```js title="whiteboard.js" const store = createDendriStore(DENDRI_OPTIONS); const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); const history = []; // every segment we've seen, for replays // --- drawing locally → broadcast segments ------------------------------- let drawing = false; let prev = null; canvas.addEventListener("pointerdown", (e) => { drawing = true; prev = point(e); }); canvas.addEventListener("pointerup", () => { drawing = false; prev = null; }); canvas.addEventListener("pointermove", (e) => { if (!drawing) return; const next = point(e); const segment = { from: prev, to: next, color: myColor, width: 3 }; drawSegment(segment); history.push(segment); store.broadcast(segment, { topic: "draw" }); prev = next; }); // --- receiving remote segments ------------------------------------------ store.subscribe("draw", (segment) => { drawSegment(segment); history.push(segment); }); // --- late-joiner catch-up ------------------------------------------------- // The host answers "history" RPCs with everything drawn so far. store.onHostChanged(() => { // (Re-)register on whichever peer is currently host, including after migration. }); function drawSegment({ from, to, color, width }) { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(from.x * canvas.width, from.y * canvas.height); ctx.lineTo(to.x * canvas.width, to.y * canvas.height); ctx.stroke(); } function point(e) { const r = canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; } store.join("whiteboard-room"); ``` For a whiteboard that needs *guaranteed* convergence (undo, erasing, reordering), skip manual history and model strokes in a Yjs document instead; that's exactly what [collaborative-layers](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-layers) does. See [Collaborative editing](https://dendri.dev/docs/collaboration/). ## File transfer **Live demo:** [file-transfer.dendri.dev](https://file-transfer.dendri.dev/). Open it in two tabs to see the sync. *Concepts: direct 1:1 data connections, binary chunking, backpressure.* Files go peer-to-peer over a dedicated reliable data connection, so the server never sees the bytes. Use the low-level `Dendri` API for this: ```js title="sender.js" import { Dendri } from "@afterrealism/dendri-client"; const peer = new Dendri(DENDRI_OPTIONS); const CHUNK_SIZE = 64 * 1024; // 64 KiB async function sendFile(remotePeerId, file) { const conn = peer.connect(remotePeerId, { reliable: true }); conn.on("open", async () => { // 1. Announce what's coming. conn.send({ kind: "meta", name: file.name, size: file.size, mime: file.type }); // 2. Stream the chunks. for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) { const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer(); conn.send({ kind: "chunk", offset, chunk }); } // 3. Signal completion. conn.send({ kind: "done" }); }); } ``` ```js title="receiver.js" peer.on("connection", (conn) => { let meta = null; const parts = []; conn.on("data", (msg) => { if (msg.kind === "meta") { meta = msg; parts.length = 0; } else if (msg.kind === "chunk") { parts.push(msg.chunk); updateProgress(parts.length * 64 * 1024, meta.size); } else if (msg.kind === "done") { const blob = new Blob(parts, { type: meta.mime }); offerDownload(blob, meta.name); // URL.createObjectURL + } }); }); ``` - Use a **room** (or the store) first to discover peer IDs, then open the direct connection to the chosen peer. - Binary payloads are serialized with BinaryPack automatically; large messages are chunked on the wire. - Full demo with progress UI and drag-and-drop: [file-transfer](https://github.com/afterrealism/dendri-examples/tree/main/file-transfer). ## Video chat **Live demo:** [video-chat.dendri.dev](https://video-chat.dendri.dev/). Open it in two tabs to see the sync. *Concepts: media connections, room discovery.* Media calls use the same peer IDs as data: get a local `MediaStream`, call each peer in the room, and answer incoming calls. A deterministic "who calls whom" rule prevents double calls: ```js title="video.js" import { Dendri } from "@afterrealism/dendri-client"; const peer = new Dendri(DENDRI_OPTIONS); const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true, }); addVideoTile("me", localStream, { muted: true }); peer.on("open", () => { peer.joinRoom("video-room"); }); // The server tells us who's in the room. peer.on("roomPeers", (room, peers) => { for (const id of peers) { if (id === peer.id) continue; // Deterministic rule: the lexicographically smaller ID initiates. if (peer.id < id) { const call = peer.call(id, localStream); call.on("stream", (remote) => addVideoTile(id, remote)); call.on("close", () => removeVideoTile(id)); } } }); // Answer calls from peers that joined after us. peer.on("call", (call) => { call.answer(localStream); call.on("stream", (remote) => addVideoTile(call.peer, remote)); call.on("close", () => removeVideoTile(call.peer)); }); function addVideoTile(id, stream, { muted = false } = {}) { const video = document.createElement("video"); video.autoplay = true; video.playsInline = true; video.muted = muted; // always mute your own tile video.srcObject = stream; document.querySelector("#grid").appendChild(video); video.dataset.peer = id; } ``` - Set `fetchTurnCredentials: true`: video is exactly where symmetric NATs bite. - Toggle mute/camera by flipping `track.enabled` on the local stream's tracks; no renegotiation needed. - Full demo: [video-chat](https://github.com/afterrealism/dendri-examples/tree/main/video-chat). ## Multiplayer game **Live demo:** [game.dendri.dev](https://game.dendri.dev/). Open it in two tabs to see the sync. *Concepts: RPC, host authority, host migration.* Real-time games need one source of truth. In Dendri's star topology the host is the natural authority: clients send *intents* via RPC, the host validates them and broadcasts the new state. ```js title="game.js" import { Dendri, Room } from "@afterrealism/dendri-client"; const room = new Room("game-42", { migrationTimeout: 5000 }); let state = { players: {}, ball: { x: 0.5, y: 0.5 } }; room.join(Dendri, DENDRI_OPTIONS); room.on("joined", (peerId, isHost) => { if (isHost) becomeAuthority(); }); // If the host disconnects, a deterministic election promotes a new one. room.on("hostChanged", (newHostId) => { if (room.peerId === newHostId) becomeAuthority(); }); function becomeAuthority() { // Clients call "move"; only the host validates and applies it. room.registerRpcMethod("move", (payload, sender) => { const { dx, dy } = payload; if (Math.abs(dx) > 1 || Math.abs(dy) > 1) return { ok: false }; // anti-cheat state = applyMove(state, sender, dx, dy); room.broadcast(state, { topic: "state" }); // fan out the new truth return { ok: true }; }); } // --- client side ---------------------------------------------------------- room.subscribe("state", (newState) => { state = newState; render(state); }); document.addEventListener("keydown", async (e) => { const dir = keyToDirection(e.key); if (!dir) return; // Send the intent to the host and await confirmation. await room.performRpc("move", dir, { peerId: room.hostId, timeout: 2000 }); }); ``` - **Intents in, state out:** clients never mutate shared state directly; that's what keeps peers consistent and cheating hard. - **Re-register RPC methods on `hostChanged`**: a migrated host is a different peer with a clean slate. - For guaranteed delivery of critical events (game over, score), use `room.broadcastWithAck(data)` which resolves when every connected peer confirms receipt. - Full demo: [game](https://github.com/afterrealism/dendri-examples/tree/main/game). ## Collaborative editing **Live demo:** [collaborative-edit.dendri.dev](https://collaborative-edit.dendri.dev/). Open it in two tabs to see the sync. Text editors, kanban boards, and design canvases need CRDT-grade conflict resolution, and that's a separate guide: [Collaborative editing with Yjs](https://dendri.dev/docs/collaboration/), which powers the [collaborative-edit](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-edit) and [collaborative-layers](https://github.com/afterrealism/dendri-examples/tree/main/collaborative-layers) demos. Source: https://dendri.dev/docs/examples/ --- # Collaborative editing with Yjs `@afterrealism/dendri-y` bridges a [Yjs](https://yjs.dev) document over a Dendri room. Yjs gives you conflict-free merging (CRDTs); Dendri gives you the peer-to-peer transport. Together: Google-Docs-style collaboration with no document server. ## When you need a CRDT Topics and broadcasts (previous chapters) are great for *events*. But when several people edit the *same data* — a text document, a todo list, a canvas of shapes — concurrent edits conflict, and last-write-wins destroys work. Yjs models the data as a CRDT so every peer converges to the same result regardless of message order, offline edits, or who joined when. ## Install ```bash title="terminal" npm install @afterrealism/dendri-client @afterrealism/dendri-y yjs y-protocols ``` > **Warning:** **Peer dependencies matter here.** `yjs`, `y-protocols`, and `@afterrealism/dendri-client` are peer dependencies of `dendri-y` on purpose: Yjs requires a *single instance* across your app. Two copies of Yjs silently break CRDT identity, and two copies of the Dendri client double your WebSocket connections. Install all four at the app level, exactly as above. ## Basic setup ```ts title="collab.ts" import { createDendriStore } from "@afterrealism/dendri-client"; import { DendriYjsProvider } from "@afterrealism/dendri-y"; import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; // 1. A normal Dendri room store (see Configuration for options). const room = createDendriStore({ url: "wss://signal.example.com", enableRelay: true, fetchTurnCredentials: true, }); // 2. The shared document + optional awareness (cursors, selections, names). const doc = new Y.Doc(); const awareness = new Awareness(doc); // 3. Bridge them over the room's reserved "__yjs" topic. const provider = new DendriYjsProvider({ room, doc, awareness }); // 4. Join. Sync starts automatically as peers arrive. room.join("document-42"); // …later, during app cleanup — order matters: provider.destroy(); room.destroy(); doc.destroy(); ``` That's the entire integration. When a peer joins, the provider exchanges state vectors (SyncStep1/SyncStep2), catches the newcomer up, and then streams incremental updates. Yjs updates are idempotent and commutative, so no extra delivery guarantees are needed. ## Working with shared types Everything below is standard Yjs — Dendri is just the transport. A collaborative todo list: ```ts title="todos.ts" const todos = doc.getArray("todos"); // Local edits — automatically replicated to every peer. function addTodo(text: string) { todos.push([{ text, done: false, id: crypto.randomUUID() }]); } function toggleTodo(index: number) { const item = todos.get(index); doc.transact(() => { todos.delete(index, 1); todos.insert(index, [{ ...item, done: !item.done }]); }); } // Remote (and local) changes — re-render from the shared state. todos.observe(() => { renderList(todos.toArray()); }); ``` For rich text, bind `doc.getText("content")` to an editor with the standard Yjs bindings: | Editor | Binding | | --- | --- | | CodeMirror 6 | `y-codemirror.next` | | ProseMirror / Tiptap | `y-prosemirror` | | Quill | `y-quill` | | Monaco | `y-monaco` | ```ts title="editor.ts (CodeMirror 6)" import { EditorView, basicSetup } from "codemirror"; import { yCollab } from "y-codemirror.next"; const ytext = doc.getText("content"); new EditorView({ doc: ytext.toString(), extensions: [basicSetup, yCollab(ytext, awareness)], parent: document.querySelector("#editor"), }); ``` ## Awareness: cursors, selections, names Awareness is Yjs's ephemeral side channel — per-peer state that isn't part of the document and disappears when a peer leaves. It's ideal for cursors and "who's viewing". The [cursors demo](https://github.com/afterrealism/dendri-examples/tree/main/cursors) is built entirely on it: ```ts title="awareness-cursors.ts" // Publish my cursor (throttled — see the cursors walkthrough). awareness.setLocalStateField("user", { name: "Ana", color: "#22c55e" }); window.addEventListener("pointermove", (e) => { awareness.setLocalStateField("cursor", { x: e.clientX / innerWidth, y: e.clientY / innerHeight, }); }); // Render everyone else's cursor. awareness.on("change", () => { for (const [clientId, state] of awareness.getStates()) { if (clientId === awareness.clientID) continue; if (state.cursor) drawCursor(clientId, state.cursor, state.user); } }); ``` The provider removes remote awareness states automatically when peers leave the room, so stale cursors clean themselves up. ## Wire format The provider reserves the `__yjs` topic and frames every message as one header byte followed by raw Yjs bytes (sent with `broadcastBinary`): | Byte | Type | Payload | | --- | --- | --- | | `0` | `SyncStep1` | `Y.encodeStateVector(doc)` | | `1` | `SyncStep2` | `Y.encodeStateAsUpdate(doc, remoteVector)` | | `2` | `Update` | Incremental update from `doc.on("update")` | | `3` | `Awareness` | `encodeAwarenessUpdate(awareness, ids)` | On peer-join the new peer sends `SyncStep1`, remotes respond with `SyncStep2`, and both sides stream `Update` frames thereafter. Don't publish your own messages on the `__yjs` topic. ## Persistence and offline Dendri is transport, not storage — when the last peer leaves, the document lives only in their browser. For durability, combine the provider with a local persistence layer like `y-indexeddb`; multiple providers on one doc compose cleanly: ```ts title="persistence.ts" import { IndexeddbPersistence } from "y-indexeddb"; const local = new IndexeddbPersistence("document-42", doc); // load + autosave locally const provider = new DendriYjsProvider({ room, doc, awareness }); // sync with peers ``` ## Requirements - `@afterrealism/dendri-client` ≥ 2.3.7 (for `room.broadcastBinary()`) - `yjs` ^13.6.0 - `y-protocols` ^1.0.0 Source: https://dendri.dev/docs/collaboration/ --- # Migrating from PeerJS Dendri's low-level API is deliberately PeerJS-shaped: `connect()`, `call()`, the same event names. Most apps migrate in under an hour, and gain reconnection that keeps your ID, transport fallback, rooms, presence, and a maintained server. ## Why teams migrate - **Reconnection that works.** PeerJS's long-standing "ID is taken" issue on reconnect does not exist here: `reconnect()` re-establishes signaling with your previous ID. - **A connection that survives hostile networks.** PeerJS gives up when WebRTC is blocked. Dendri falls back through STUN, TURN, and an end-to-end encrypted TLS relay; signaling itself falls back WebSocket → SSE → long-poll. - **Rooms, presence, RPC, and ACK delivery** as first-class APIs instead of hand-rolled connection bookkeeping. - **A maintained, self-hostable server.** The Rust signaling server is a single binary you run yourself (or use the hosted tier). No best-effort community cloud. ## Constructor and options ```ts title="before / after" // PeerJS import Peer from "peerjs"; const peer = new Peer("alice", { host: "peer.example.com", port: 443, path: "/", secure: true, key: "peerjs", debug: 2, }); // Dendri import Dendri from "@afterrealism/dendri-client"; const peer = new Dendri("alice", { url: "wss://signal.example.com", // replaces host/port/path/secure key: "dendri", debug: 2, }); ``` | PeerJS option | Dendri equivalent | Notes | | --- | --- | --- | | `host`, `port`, `path`, `secure` | `url` (or the same four options) | One URL replaces four fields; the explicit fields still work. | | `key` | `key` | Defaults to `"dendri"`; must match the server's `--key`. | | `config` | `config` | Same `RTCConfiguration` shape for custom ICE servers. | | `debug` | `debug` | Same 0 to 3 scale. | | (none) | `fetchTurnCredentials` | Ask the server to mint short-lived TURN credentials. | | (none) | `enableRelay` | Opt in to the tier-4 encrypted relay fallback. | | (none) | `apiKey`, `jwt` | Hosted-tier key or JWT auth for locked-down servers. | > **Warning:** **There is no default cloud.** PeerJS silently connects to its community server when you omit options. Dendri never does: you always pass your own server URL (or your hosted endpoint). If the URL is missing you get a clear error, not a surprise dependency. ## Data connections The shape you already know. `connect()` returns a connection that emits `open`, `data`, `close`, and `error`: ```ts title="identical in both" const conn = peer.connect("bob", { reliable: true }); conn.on("open", () => conn.send({ hello: "world" })); conn.on("data", (data) => console.log(data)); peer.on("connection", (conn) => { conn.on("data", (data) => console.log("got", data)); }); ``` Upgrades you can adopt incrementally: `peer.connectHybrid()` adds automatic WebRTC ⇄ relay fallback with upgrade retries, and `MsgPackDendri` is a drop-in subclass that serializes with MsgPack for object-heavy traffic. ## Media calls ```ts title="identical in both" // caller const call = peer.call("bob", localStream); call.on("stream", (remote) => (video.srcObject = remote)); // callee peer.on("call", (call) => { call.answer(localStream); call.on("stream", (remote) => (video.srcObject = remote)); }); ``` ## Peer events | Event | PeerJS | Dendri | | --- | --- | --- | | `open`, `connection`, `call`, `close`, `disconnected`, `error` | Yes | Yes, same payloads | | `reconnected` | No | Signaling re-established; room state re-sent automatically. | | `connectionStateChanged` | No | Observe the full connection state machine. | | `roomPeers`, `presenceUpdate` | No | Room membership and presence, no manual bookkeeping. | ## The server Replace `peerjs-server` with the Dendri signaling server, a single Rust binary with Redis state: ```bash title="terminal" # before: npx peerjs --port 9000 # after (Redis + server) podman run -d --name dendri-redis -p 6379:6379 docker.io/redis:7 podman run -d --name dendri --network host \ ghcr.io/afterrealism/dendri-server:latest \ --host 127.0.0.1 --port 9876 \ --redis_url redis://127.0.0.1:6379 \ --enable_relay --allow_discovery ``` Full flags, TLS, and TURN are covered in [Self-hosting](https://dendri.dev/docs/self-hosting/). ## Beyond parity: what to adopt next Once the port compiles, the payoff is the layer PeerJS never had. Replace hand-rolled connection fan-out with a [Room](https://dendri.dev/docs/api/#room), or skip the class entirely and use [createDendriStore()](https://dendri.dev/docs/api/#store): ```ts title="rooms instead of bookkeeping" import { createDendriStore } from "@afterrealism/dendri-client"; const store = createDendriStore({ url: "wss://signal.example.com" }); store.join("my-room"); // discovery, host election, reconnects store.broadcast({ cursor: { x, y } }); // fan-out to every peer store.subscribe("chat", (msg, from) => …); // topics store.setPresence({ name: "Alice" }); // typed presence ``` > **Tip:** **Migrating with an AI agent?** Point it at the [Dendri MCP server](https://dendri.dev/docs/ai/) and ask it to port your PeerJS code; the `dendri_get_example` tool gives it verified patterns to copy. Source: https://dendri.dev/docs/migrate-peerjs/ --- # API reference The essentials of every public class in `@afterrealism/dendri-client` and `@afterrealism/dendri-y`. Full option types are in [Configuration](https://dendri.dev/docs/configuration/); TypeScript declarations ship with the packages. ## Package entry points | Import path | Exports | | --- | --- | | `@afterrealism/dendri-client` | `Dendri`, `MsgPackDendri`, `Room`, `createDendriStore`, `DendriServerAPI`, errors, enums, types. | | `…/store` | Store-only bundle: `createDendriStore` and store types. | | `…/msgpack` | MsgPack wire serializer. | | `…/react` | `useDendriStore` hook (peer dep: react ≥ 18). | | `…/vue` | `useDendriStore` composable (peer dep: vue ≥ 3.3). | | `…/svelte` | `toSvelteStore` (no dependency). | | `@afterrealism/dendri-y` | `DendriYjsProvider`, `DendriRoomLike`. | ## class Dendri A peer: one identity plus a signaling connection. Extends a typed event emitter. ```ts title="constructors" new Dendri(options: DendriOptions) new Dendri(id: string, options?: DendriOptions) ``` ### Properties | Property | Type | Description | | --- | --- | --- | | `id` | `string \| null` | The peer's brokering ID (assigned by the server unless you passed one). | | `open` | `boolean` | `true` once connected to the signaling server. | | `connectionState` | `ConnectionState` | Current state machine value (see [lifecycle](https://dendri.dev/docs/configuration/#lifecycle)). | | `disconnected` | `boolean` | Whether the signaling connection is currently down. | | `destroyed` | `boolean` | Whether `destroy()` has been called. | ### Methods | Method | Returns | Description | | --- | --- | --- | | `connect(peerId, options?)` | `DataConnection` | Open a data connection to a remote peer. Options: `label`, `metadata`, `serialization`, `reliable`. | | `call(peerId, stream, options?)` | `MediaConnection` | Start an audio/video call with a local `MediaStream`. | | `connectHybrid(peerId, options?)` | `HybridConnection` | Data connection with automatic WebRTC ⇄ relay fallback and upgrade retries. | | `joinRoom(name)` / `leaveRoom(name)` | `void` | Low-level room membership (the `Room` class manages this for you). | | `getConnection(peerId, connectionId)` | `DataConnection \| MediaConnection \| null` | Look up an existing connection. | | `disconnect()` | `void` | Close the signaling connection but keep existing P2P connections alive. | | `reconnect()` | `void` | Re-establish signaling with the previous ID. | | `destroy()` | `void` | Close everything: all connections plus signaling. Terminal. | | `diagnostics()` | `object` | Snapshot of connection internals for debugging. | ### Events | Event | Payload | Fired when | | --- | --- | --- | | `open` | `(id: string)` | Connected to the signaling server. | | `connection` | `(conn: DataConnection)` | A remote peer opened a data connection to you. | | `call` | `(call: MediaConnection)` | A remote peer is calling you. | | `close` | — | The peer was destroyed. | | `disconnected` | `(currentId: string)` | Signaling connection dropped (auto-reconnect kicks in). | | `reconnected` | — | Signaling successfully re-established; room state is re-sent. | | `error` | `(err: DendriError)` | Fatal or connection-level errors. | | `roomPeers` | `(room: string, peers: string[])` | The server sent the current peer list for a room. | | `presenceUpdate` | `(peerId, room, data)` | A peer updated its presence. | | `connectionStateChanged` | `(state, previous)` | The connection state machine transitioned. | `MsgPackDendri` is a drop-in subclass that serializes data connections with MsgPack instead of JSON/BinaryPack — smaller payloads for object-heavy traffic. ## DataConnection & MediaConnection | Member | On | Description | | --- | --- | --- | | `send(data)` | Data | Send any serializable payload (binary supported; large messages are chunked). | | `answer(stream?)` | Media | Accept an incoming call, optionally attaching your own stream. | | `close()` | Both | Tear down the connection. | | `open` | Both | `true` once usable. | | `peer` | Both | The remote peer's ID. | | `metadata` / `label` | Data | Values set by the initiator at `connect()` time. | | `localStream` / `remoteStream` | Media | The attached media streams. | | `on("open" \| "data" \| "close" \| "error")` | Data | Lifecycle + payload events. | | `on("stream" \| "close" \| "error")` | Media | `stream` delivers the remote `MediaStream`. | ## class Room Star-topology room lifecycle: host claiming, peer discovery, reconnection, and deterministic host migration. ```ts title="usage" import { Dendri, Room } from "@afterrealism/dendri-client"; const room = new Room("room-id", { migrationTimeout: 10_000 }); room.join(Dendri, { url: "wss://signal.example.com" }); ``` ### Members | Member | Description | | --- | --- | | `isHost` / `hostId` / `peerId` | Topology info for this peer. | | `peers` / `peerCount` | Known remote peers (excluding self). | | `broadcast(data, { topic? })` | Send to all peers; falls back to the server relay when P2P isn't available. | | `broadcastBinary(bytes, { topic })` | Broadcast a `Uint8Array`; receivers get bytes back intact on every path. | | `broadcastWithAck(data, timeoutMs?)` | Resolves when every connected peer has ACKed (at-least-once delivery). | | `subscribe(topic, handler)` | Receive messages on a topic. Returns an unsubscribe function. | | `onData(handler)` | Receive all data regardless of topic. | | `registerRpcMethod(name, handler)` | Expose a method other peers can call. Returns an unregister function. | | `performRpc(method, payload, { timeout?, peerId? })` | Call a remote method; targets one peer or broadcasts to all handlers. | | `setPresence(data)` / `getPresence(peerId)` / `getOthers()` | Publish and read presence state. | | `leave()` | Leave the room and clean up all connections and managers. | ### Events | Event | Payload | Fired when | | --- | --- | --- | | `joined` | `(peerId, isHost)` | This peer joined the room. | | `peerJoined` / `peerLeft` | `(peerId)` | A remote peer joined or left. | | `data` | `(peerId, data)` | Data received from any peer. | | `hostChanged` | `(newHostId)` | Host migration completed. | | `error` | `(err: Error)` | Room-level failures. | ## createDendriStore() A framework-agnostic reactive wrapper around a `Room`. See [Framework guides](https://dendri.dev/docs/frameworks/) for usage; the surface: ```ts title="DendriStore" interface DendriStore { // Reactive state (also available as an immutable snapshot) readonly connectionState: ConnectionState; readonly isHost: boolean; readonly myPeerId: string | null; readonly hostId: string | null; readonly peers: readonly string[]; readonly peerCount: number; readonly presences: ReadonlyMap; // Actions join(roomId: string): void; leave(): void; broadcast(data: unknown, options?: { topic?: string }): void; broadcastBinary(bytes: Uint8Array, options: { topic: string }): void; broadcastWithAck(data: unknown, timeout?: number): Promise; setPresence(data: Record): void; // Event registration — each returns an unsubscribe function onPeerJoin(handler: (peerId: string) => void): () => void; onPeerLeave(handler: (peerId: string) => void): () => void; onData(handler: (data: unknown, peerId: string) => void): () => void; onPresenceUpdate(handler: (peerId: string, data: unknown) => void): () => void; onHostChanged(handler: (hostId: string) => void): () => void; // Framework integration subscribe(listener: () => void): () => void; // state changes subscribe(topic: string, handler: (data, peerId) => void): () => void; // topic messages getSnapshot(): DendriStoreSnapshot; destroy(): void; } ``` ## Framework adapters | Import | Signature | Notes | | --- | --- | --- | | `…/react` | `useDendriStore(store): DendriStoreSnapshot` | Built on `useSyncExternalStore`; concurrent-safe. | | `…/vue` | `useDendriStore(store): Readonly>` | Auto-unsubscribes on unmount. | | `…/svelte` | `toSvelteStore(store): SvelteReadable` | Standard store contract; works with `$` syntax in Svelte 4 and 5. | ## class DendriServerAPI Typed client for the server's REST endpoints: | Method | Returns | | --- | --- | | `getHealth()` | Liveness payload from `/health`. | | `listPeers(room?)` | Peer IDs (server must run with `--allow_discovery`). | | `generatePeerId()` | A fresh server-generated peer ID. | | `getTurnCredentials()` | Ephemeral `{ iceServers }` (server must run with `--turn_secret`). | | `getAnalytics()` | Analytics dataset descriptor. | ## class DendriYjsProvider ```ts title="@afterrealism/dendri-y" new DendriYjsProvider(options: { room: DendriRoomLike; // a DendriStore satisfies this doc: Y.Doc; awareness?: Awareness; }) provider.destroy(): void; // detach listeners, clean up awareness state ``` `DendriRoomLike` is the minimal surface the provider needs (`myPeerId`, `broadcastBinary`, `subscribe`, `onPeerJoin`, `onPeerLeave`) — any object implementing it works, which is how the provider stays framework- and transport-agnostic. Full guide: [Collaborative editing](https://dendri.dev/docs/collaboration/). ## Errors Connection-level failures surface as `DendriError`, which carries a stable `type` (e.g. `ID_TAKEN`, `NETWORK`, `SERVER_ERROR`), a `retryable` flag, and optional `details`. Always attach an `error` listener to peers and rooms — unhandled peer errors are fatal to the peer. Source: https://dendri.dev/docs/api/ --- # Self-hosting The signaling server is a single Rust (Axum/Tokio) binary backed by Redis. Run it on a $5 VPS, point the SDK at it, and the entire realtime stack is yours: no usage caps, no vendor. ## Requirements - **Redis** (or a Redis-compatible store): default `redis://127.0.0.1:6379`, override with `--redis_url`. - **A container runtime** (Podman or Docker), or a Rust toolchain to build from source. - For production: a domain with TLS (either terminate at the server with `--sslkey`/`--sslcert`, or put it behind a reverse proxy). - For strict-NAT users: a **TURN server** such as coturn (below). ## Run with Podman / Docker ```bash title="terminal" # Redis podman run -d --name dendri-redis -p 6379:6379 docker.io/redis:7 # Signaling server podman run -d --name dendri \ -p 9876:9876 \ ghcr.io/afterrealism/dendri-server:latest \ --host 0.0.0.0 --port 9876 \ --redis_url redis://host.containers.internal:6379 \ --enable_relay ``` A production-grade Podman Quadlet stack (server + Redis + coturn + website, with systemd units) lives in [dendri-infrastructure](https://github.com/afterrealism/dendri-infrastructure), including Terraform for VM provisioning. ## Run from source ```bash title="terminal" git clone https://github.com/afterrealism/dendri-server cd dendri-server cargo build --release # Local development cargo run -- --host 127.0.0.1 --port 9876 --enable_relay --allow_discovery # With TLS terminated by the server itself cargo run -- --host 0.0.0.0 --port 443 \ --sslkey /path/to/key.pem --sslcert /path/to/cert.pem \ --enable_relay ``` ## Key configuration flags Every flag can also be set via environment variable; the full reference is in the [server repo](https://github.com/afterrealism/dendri-server). | Flag | Env | Default | Purpose | | --- | --- | --- | --- | | `--port` | `PORT` | `9000` | Listen port. | | `--host` | — | `::` | Bind address. | | `--key` | `DENDRI_KEY` | `dendri` | Connection key namespace (must match the client's `key`). | | `--path` | `PEERSERVER_PATH` | `/` | URL prefix (must match the client's `path`). | | `--redis_url` | — | `redis://127.0.0.1:6379` | State backend. | | `--enable_relay` | `DENDRI_ENABLE_RELAY` | `false` | Tier-4 encrypted WebSocket relay. | | `--allow_discovery` | `DENDRI_ALLOW_DISCOVERY` | `false` | Enable the `GET /:key/peers` listing endpoint. | | `--turn_secret` | `TURN_SECRET` | — | HMAC secret for minting ephemeral TURN credentials. | | `--turn_servers` | — | — | TURN server URLs handed to clients. | | `--jwt_secret` | `DENDRI_JWT_SECRET` | — | Require and validate JWTs from clients. | | `--sslkey` / `--sslcert` | — | — | Terminate TLS in the server itself. | | `--cors` | — | — | Allowed CORS origins (repeatable). | | `--concurrent_limit` | — | `10000` | Max simultaneous clients. | ## HTTP endpoints | Method | Path | Description | | --- | --- | --- | | `GET` | `/health` | Liveness probe. | | `GET` | `/metrics` | Prometheus metrics. | | `GET` | `/{base}/:key/id` | Generate a peer ID. | | `GET` | `/{base}/:key/peers` | List peers (requires `--allow_discovery`). | | `GET` | `/{base}/:key/turn-credentials` | Ephemeral TURN credentials (requires `--turn_secret`). | | `GET` | `/{base}/dendri` | WebSocket upgrade; the primary signaling transport. | | `GET` | `/{base}/http/sse` | Server-Sent Events fallback transport. | | `POST` | `/{base}/http/send` | HTTP send (pairs with SSE / long-poll). | | `GET` | `/{base}/http/poll` | HTTP long-poll fallback transport. | ## TURN with coturn Without TURN, peers behind symmetric NAT (many mobile carriers, some offices) can't establish direct connections. Run coturn next to the server with a shared HMAC secret: ```bash title="/etc/turnserver.conf" listening-port=3478 fingerprint use-auth-secret static-auth-secret=YOUR_SHARED_SECRET realm=turn.example.com ``` ```bash title="server flags" --turn_secret YOUR_SHARED_SECRET \ --turn_servers turn:turn.example.com:3478 ``` Clients then opt in with `fetchTurnCredentials: true`: the SDK fetches short-lived credentials from `/turn-credentials` before connecting, so no static TURN passwords ever ship in your frontend bundle. ## Pointing the SDK at your server ```ts title="app.ts" const store = createDendriStore({ url: "wss://signal.example.com", // your domain fetchTurnCredentials: true, enableRelay: true, signalingTransport: "auto", }); ``` > **Note:** **The client API is identical** whether you self-host or use the hosted tier; only the URL (and `apiKey`) changes. No feature gates. ## Scaling and operations - **State lives in Redis**, so the server process is restartable without dropping room state; clients auto-reconnect and re-send membership. - **Monitoring:** scrape `/metrics` with Prometheus; probe `/health` from your load balancer. - **Sizing:** signaling is tiny: handshakes and heartbeats only. The routing hot path costs nanoseconds in-process (criterion: ~56 ns peer lookup, ~44 ns fan-out to a 50-peer room), so the default `--concurrent_limit 10000` is realistic on a small VM — the data path is peer-to-peer, and rooms cap at `--max_room_size 50` by default. TURN bandwidth (when used) is the real cost driver. - **Reverse proxy:** if you terminate TLS at nginx/Caddy, make sure WebSocket upgrade headers are forwarded and idle timeouts exceed the 60 s heartbeat. ## Licensing | Component | License | What it means for you | | --- | --- | --- | | `@afterrealism/dendri-client`, `@afterrealism/dendri-y` | Apache-2.0 | Use, modify, and embed in any app (commercial or closed-source) with attribution. | | `dendri-server`, `dendri-infrastructure` | AGPL-3.0 | Run it freely. If you modify it and offer it as a network service, you must publish your modifications under AGPL. | Running the *unmodified* server for your own product is fine and requires nothing beyond keeping the license notice. If you need to modify the server and keep those changes private while offering it as a service, a commercial license is available from Afterrealism: [get in touch](mailto:sheecegardezi@pm.me). Source: https://dendri.dev/docs/self-hosting/ --- # AI & agents Building with Dendri through a coding agent? Every docs page ships as plain markdown, the whole site is indexed in `llms.txt`, and the hosted MCP endpoint at `https://dendri.dev/mcp` puts the docs one tool call away in Claude Code, opencode, Cursor, or VS Code. ## llms.txt Dendri publishes the [llms.txt](https://llmstxt.org/) convention at the site root: | URL | Contents | | --- | --- | | [`/llms.txt`](https://dendri.dev/llms.txt) | Curated index: what Dendri is, package names, server routes, and links to every docs page. | | [`/llms-full.txt`](https://dendri.dev/llms-full.txt) | Every docs page inlined as one markdown file, paste-able into any context window. | If your tool can fetch URLs, pointing it at `/llms-full.txt` is the fastest way to give it complete, current knowledge of the SDK and server setup. ## Markdown mirror of every page Each docs page is also served as clean markdown at `index.md` next to the HTML; same content, no chrome, ideal for agents: ```bash title="markdown URLs" curl https://dendri.dev/docs/quickstart/index.md curl https://dendri.dev/docs/configuration/index.md curl https://dendri.dev/docs/frameworks/index.md curl https://dendri.dev/docs/examples/index.md curl https://dendri.dev/docs/collaboration/index.md curl https://dendri.dev/docs/api/index.md curl https://dendri.dev/docs/self-hosting/index.md ``` ## The MCP server Dendri runs a hosted [Model Context Protocol](https://modelcontextprotocol.io/) endpoint at `https://dendri.dev/mcp` (Streamable HTTP, stateless, no API key; it serves only these public docs). Point your agent at the URL and it gets the tools below. | Tool | What it returns | | --- | --- | | `dendri_list_docs` | Every docs page with slug, title, and summary. | | `dendri_get_doc` | One full page as markdown (`quickstart`, `api`, `self-hosting`, …). | | `dendri_search_docs` | Sections matching a keyword query, ranked, with source URLs. | | `dendri_get_example` | A complete, documented example: chat, live cursors, presence, whiteboard, file transfer, video chat, multiplayer game, collaborative editing, or the React/Vue/Svelte integrations. | | `dendri_api_reference` | One API area: `dendri`, `room`, `store`, `connections`, `adapters`, `server-api`, `yjs-provider`, `errors`, `entry-points`. | | `dendri_get_config_option` | Matching rows of the client option reference for a name or substring (`apiKey`, `turn`, `relay`, …). | Every docs page is also exposed as an MCP *resource* (`dendri://docs/`) for clients that attach resources instead of calling tools. ### Claude Code ```bash title="terminal" claude mcp add --transport http dendri-docs https://dendri.dev/mcp ``` ### opencode ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", "mcp": { "dendri-docs": { "type": "remote", "url": "https://dendri.dev/mcp", "enabled": true } } } ``` ### Cursor ```json title=".cursor/mcp.json" { "mcpServers": { "dendri-docs": { "url": "https://dendri.dev/mcp" } } } ``` ### VS Code ```json title=".vscode/mcp.json" { "servers": { "dendri-docs": { "type": "http", "url": "https://dendri.dev/mcp" } } } ``` ## What agents see, and what they never see The MCP server and the markdown mirror are generated from these public docs pages and nothing else. They contain: - **SDK usage**: installing `@afterrealism/dendri-client` and `@afterrealism/dendri-y`, every client option, the framework adapters. - **Working example code**: the same walkthroughs as [Building the examples](https://dendri.dev/docs/examples/), written and verified against the real SDK. - **Deployment how-to**: running the server container, flags, TLS, TURN, Redis. They deliberately do *not* contain: - **Signaling-server source code.** The docs describe how to *run* the published server image, not its internals, so an agent can never paste AGPL server code into your Apache-2.0 (or proprietary) app by accident. - **Credentials or endpoints.** Dendri is self-host-first: every generated snippet takes *your* server URL. There is no default host an agent could silently connect you to. ## Prompting tips The examples in these docs are the gold standard: they use the current API and handle cleanup, reconnection, and host migration correctly. Steer your agent toward them: ```bash title="example prompt" # With dendri-docs MCP connected: "Use dendri_get_example('live-cursors') as the reference pattern and build a cursor overlay for my React app. My signaling server runs at wss://signal.example.com — do not invent any other endpoint." ``` > **Tip:** **Pin the pattern, not the vibe.** Asking the agent to fetch the relevant doc or example first (one tool call) produces dramatically better Dendri code than letting it improvise from training data. Source: https://dendri.dev/docs/ai/