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 AppThe 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.
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).
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!' });
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
| 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. |
| 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. |
engine.onMessage((msg) => {
if (msg.type === 'edit') {
editor.applyDelta(msg.delta);
}
});
editor.onChange(delta => {
engine.send({ type: 'edit', delta });
});
// 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);
}
});
The engine transparently serialises and deserialises data so you can work with native JS types.
| Sent as | Received as | Notes |
|---|---|---|
| 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. |
Only used for signaling. Once connected, Firestore is idle.