Projects / Chat / engine.js

P2P Chat Engine

A reusable real-time communication module built on WebRTC data channels with Firebase Firestore for signaling. Supports text, JSON, and binary data transfer — plus a zero-backend direct mode for fully offline use.

Launch Chat App
Contents
  1. Architecture Overview
  2. ChatEngine — Firebase Signaling Mode
  3. DirectEngine — No-Backend Direct Mode
  4. Full API Reference
  5. Usage Examples
  6. Data Types
  7. Firestore Schema
01 / Overview

Architecture

The engine splits communication into two layers: signaling (coordination via Firebase Firestore) and data transfer (via WebRTC RTCDataChannel). Once the peer connection is established, Firebase is no longer involved — all messages travel directly between browsers.

A second class, DirectEngine, eliminates Firebase entirely. It produces raw SDP strings you can share by any means (copy/paste, QR, email) and establishes a direct channel with no server infrastructure.

Firebase Mode (ChatEngine)

  Peer A (host) Peer B (guest)
   │ │    │──── createRoom(id) ──────────────▶│    │ [writes offer to Firestore] │    │ │    │◀─── joinRoom(id) ─────────────────│    │ [reads offer, writes answer] │    │ │    │════════ RTCDataChannel ════════════│    │ (direct P2P) │

   Firestore used only for offer/answer/ICE exchange.    All subsequent data flows peer-to-peer via WebRTC.

Direct Mode (DirectEngine)

  Caller Callee    │ │    │─── createOffer() ─────────────────│    │ [shows SDP string] │    │ │    │◀── createAnswerFor(offer) ─────────│    │ [shows SDP answer string] │    │ │    │── setAnswer(answer) ───────────────│    │ │    │════════ RTCDataChannel ════════════│

   No server required. SDP shared via any channel.
02 / Firebase Mode

ChatEngine

ChatEngine uses Firebase Firestore as a rendezvous server. It handles WebRTC offer/answer negotiation and ICE candidate exchange automatically. Once connected, Firebase listeners are preserved only for detecting new joiners (basic mesh support).

ℹ️ Prerequisites: The host page must call firebase.initializeApp(config) and pass firebase.firestore() to engine.init() before calling any room methods.
import { ChatEngine } from '/Projects/Chat/engine.js';

// Assumes firebase.initializeApp() already called on this page
const engine = new ChatEngine();
engine.init(firebase.firestore());

// Register callbacks before joining
engine.onPeerConnected(peerId => console.log('peer joined:', peerId));
engine.onPeerDisconnected(peerId => console.log('peer left:', peerId));
engine.onMessage((data, peerId) => console.log('message from', peerId, data));

// Host: create a room
await engine.createRoom('my-room-123');

// Guest: join the same room
await engine.joinRoom('my-room-123');

// Once onPeerConnected fires, send data
engine.send({ type: 'chat', text: 'Hello!' });
03 / Direct Mode

DirectEngine

DirectEngine generates SDP strings that you exchange with the remote peer through any channel — paste them in a chat, email them, display them as QR codes. No server, no sign-in, no dependency other than a modern browser.

import { DirectEngine } from '/Projects/Chat/engine.js';

// ── Caller side ─────────────────────────────────────────
const caller = new DirectEngine();
caller.onMessage(data => console.log('received:', data));
caller.onPeerConnected(() => console.log('connected!'));

const offerSDP = await caller.createOffer();
// Display offerSDP to user — they copy/share it to callee

// After callee sends back their answer SDP:
await caller.setAnswer(answerSDP);

// ── Callee side ─────────────────────────────────────────
const callee = new DirectEngine();
callee.onMessage(data => console.log('received:', data));

const answerSDP = await callee.createAnswerFor(offerSDP);
// Display answerSDP to user — they share it back to caller
04 / Reference

API Reference

ChatEngine

Method Returns Description
init(db) void Inject firebase.firestore() instance. Required before room methods.
createRoom(roomId) Promise<string> Create a room as host. Writes offer to Firestore and listens for guests joining.
joinRoom(roomId) Promise<void> Join an existing room as guest. Reads host offer, writes answer.
send(data) void Broadcast data to all open peer channels. Accepts string, object (JSON-serialised), ArrayBuffer, or Blob.
onMessage(cb) void Register handler: cb(data, peerId). Called for every incoming message.
onPeerConnected(cb) void Register handler: cb(peerId). Called when a data channel opens.
onPeerDisconnected(cb) void Register handler: cb(peerId). Called when a peer disconnects.
disconnect() void Close all peer connections and Firestore listeners. Safe to call multiple times.

DirectEngine

Method Returns Description
createOffer() Promise<string> Generate SDP offer string (JSON). Share this with the remote peer out-of-band.
setAnswer(sdp) Promise<void> Apply the SDP answer string received from the remote peer. Completes handshake.
createAnswerFor(offer) Promise<string> Callee-side: consume offer SDP and return answer SDP string to share back.
getLocalCandidates() string[] Return accumulated ICE candidates as JSON strings (optional, for trickle ICE).
addRemoteCandidate(c) void Add a remote ICE candidate JSON string (optional, for trickle ICE).
send(data) void Send data over the data channel. Same type support as ChatEngine.
onMessage(cb) void Register message handler: cb(data, 'remote').
onPeerConnected(cb) void Register connection handler: cb('remote').
onPeerDisconnected(cb) void Register disconnection handler: cb('remote').
disconnect() void Close the peer connection and data channel.
05 / Examples

Usage Examples

Collaborative code editing

engine.onMessage((msg) => {
  if (msg.type === 'edit') {
    editor.applyDelta(msg.delta);
  }
});

editor.onChange(delta => {
  engine.send({ type: 'edit', delta });
});

Sending binary data (file transfer)

// Sender
const buffer = await file.arrayBuffer();
engine.send(buffer);  // ArrayBuffer sent as binary frame

// Receiver
engine.onMessage(data => {
  if (data instanceof ArrayBuffer) {
    const blob = new Blob([data]);
    saveBlob(blob);
  }
});
06 / Data

Data Types

The engine transparently serialises and deserialises data so you can work with native JS types.

Sent asReceived asNotes
string string Passed through as-is. Attempted JSON parse on receive (falls back to string).
object / array object / array Serialised with JSON.stringify, parsed on receive.
ArrayBuffer ArrayBuffer Sent as binary WebRTC frame. No JSON serialisation.
Blob ArrayBuffer Channel binaryType is 'arraybuffer'; Blob is sent natively.
number / boolean string Converted via String(). Wrap in an object if you need the type preserved.
07 / Firestore

Firestore Schema

Only used for signaling. Once connected, Firestore is idle.

chatRooms/ (collection)   └─ {roomId}/ (document)   ├─ createdAt: Timestamp   ├─ host: true   └─ signals/ (subcollection)   └─ {guestId}/ (document per guest)   ├─ type: "offer"   ├─ sdp: "<offer SDP string>"   ├─ answer: "<answer SDP string>" (written by host)   ├─ guestCandidates: ICECandidate[] (written by guest)   └─ hostCandidates: ICECandidate[] (written by host)
🔒 In production, add Firestore security rules to restrict who can read and write room signals. At minimum, require authenticated users or rate-limit writes per IP.