peer-client
v2.2.0
Published
Universal WebRTC peer-to-peer library with signaling, rooms, file transfer, state sync, and E2E encryption
Downloads
819
Maintainers
Readme
peer-client
Universal WebRTC peer-to-peer library with signaling, rooms, media, file transfer, state sync, CRDT, offline sync, and end-to-end encryption. Framework-agnostic.
Installation
pnpm install peer-clientQuick Start
import { PeerClient } from 'peer-client';
const client = new PeerClient({ url: 'wss://your-signal-server.com' });
await client.connect();
const peers = await client.join('my-namespace');
const peer = client.connectToPeer(peers[0].fingerprint);
peer.on('connected', () => peer.send({ hello: true }));Core API
PeerClient
Connection, signaling, namespace management, and matchmaking.
import { PeerClient } from 'peer-client';Constructor
const client = new PeerClient({
url: 'wss://signal.example.com', // required
alias: 'alice', // display name
meta: { role: 'host' }, // arbitrary metadata
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
autoReconnect: true, // default: true
reconnectDelay: 1000, // default: 1000ms
reconnectMaxDelay: 30000, // default: 30000ms
maxReconnectAttempts: 10, // default: Infinity
pingInterval: 25000, // default: 25000ms
identityKeys: exportedKeys, // optional, for persistent identity
});Methods
| Method | Returns | Description |
|---|---|---|
| connect() | Promise<void> | Connect to signaling server |
| disconnect() | void | Disconnect from server |
| join(namespace, appType?, version?) | Promise<PeerInfo[]> | Join namespace, returns existing peers |
| leave(namespace) | void | Leave namespace |
| discover(namespace, limit?) | Promise<PeerInfo[]> | Discover peers without joining |
| match(namespace, criteria?, groupSize?) | Promise<MatchResult> | Matchmaking |
| cancelMatch(namespace) | void | Cancel pending match |
| connectToPeer(fingerprint, alias?, channelConfig?) | Peer | Establish direct P2P connection |
| createPeer(fingerprint, alias) | Peer | Create peer without connecting |
| getPeer(fingerprint) | Peer \| undefined | Get existing peer |
| closePeer(fingerprint) | void | Close and remove a peer |
| relay(to, payload) | void | Send data via server relay |
| broadcast(namespace, payload) | void | Broadcast to all peers in namespace |
| createRoom(roomId, config?) | Promise<RoomCreatedResult> | Create a managed room |
| joinRoom(roomId) | Promise<PeerInfo[]> | Join existing room |
| roomInfo(roomId) | Promise<RoomInfoResult> | Get room metadata |
| kick(roomId, fingerprint) | void | Kick a peer from room |
| updateMetadata(meta) | void | Update peer metadata on server |
| getIdentity() | Identity | Get identity instance |
| getTransport() | Transport | Get transport instance |
Properties
| Property | Type | Description |
|---|---|---|
| fingerprint | string | Unique identity fingerprint |
| alias | string | Display name |
| connected | boolean | Whether WebSocket is connected |
| peerMap | ReadonlyMap<string, Peer> | All active peers |
Events
| Event | Callback | Description |
|---|---|---|
| connected | () | WebSocket connected |
| disconnected | () | WebSocket disconnected |
| registered | (fingerprint, alias) | Registered with server |
| peer_joined | (PeerJoinedInfo) | Peer joined namespace |
| peer_left | (fingerprint, namespace) | Peer left namespace |
| peer_list | (namespace, PeerInfo[]) | Peer list received |
| matched | (MatchResult) | Matchmaking result |
| relay | (from, payload) | Relay message received |
| broadcast | (from, namespace, payload) | Broadcast received |
| error | (Error) | Error occurred |
| reconnecting | (attempt, delay) | Reconnecting |
| reconnected | () | Reconnected |
| room_created | (RoomCreatedResult) | Room created |
| room_closed | (result) | Room closed |
| kicked | (payload) | Kicked from room |
Peer
Represents a direct P2P connection.
const peer = client.connectToPeer('fp-bob', 'bob');
peer.on('connected', () => {
peer.send({ hello: true });
peer.send('text', 'my-channel');
peer.sendBinary(buffer);
});Methods
| Method | Returns | Description |
|---|---|---|
| createOffer(channelConfig?) | Promise<void> | Initiate connection with offer |
| handleSignal(payload) | Promise<void> | Handle incoming signal |
| send(data, channel?) | void | Send JSON, string, or binary data |
| sendBinary(data, channel?) | void | Send ArrayBuffer explicitly |
| addStream(stream) | void | Add media stream |
| removeStream(stream) | void | Remove media stream |
| createDataChannel(config) | RTCDataChannel | Create named data channel |
| getChannel(label) | RTCDataChannel \| undefined | Get channel by label |
| getBufferedAmount(channel?) | number | Buffered bytes |
| restartIce() | void | Restart ICE negotiation |
| close() | void | Close connection |
Properties
| Property | Type | Description |
|---|---|---|
| fingerprint | string | Remote peer fingerprint |
| alias | string | Remote peer alias |
| connectionState | string | Connection state |
| channelLabels | string[] | Open data channel labels |
| closed | boolean | Whether connection is closed |
| pc | RTCPeerConnection | Underlying RTCPeerConnection |
Events
| Event | Callback | Description |
|---|---|---|
| connected | () | P2P connection established |
| disconnected | (state) | Connection lost |
| data | (data, channel) | Data received |
| stream | (MediaStream) | Media stream received |
| track | (track, streams) | Track received |
| datachannel:create | (RTCDataChannel) | Channel created |
| datachannel:open | (label, RTCDataChannel) | Channel opened |
| datachannel:close | (label) | Channel closed |
| error | (Error) | Error occurred |
Rooms
Managed P2P groups with automatic connection and relay fallback.
import { DirectRoom, GroupRoom } from 'peer-client';DirectRoom (1:1)
const room = new DirectRoom(client, 'room-123');
await room.create(); // or room.join()
room.on('data', (data, from) => {});
room.on('peer_connected', (fingerprint) => {});
room.send({ msg: 'hi' });
room.close();GroupRoom (N:N)
const room = new GroupRoom(client, 'team-room', 20);
await room.create(); // or room.join()
room.on('data', (data, from) => {});
room.on('peer_joined', (info) => {});
room.on('peer_left', (fingerprint) => {});
room.send({ msg: 'hello' }); // broadcast to all
room.send({ msg: 'dm' }, 'fp-bob'); // to specific peer
room.broadcastViaServer({ msg: 'announcement' });
room.kick('fp-bad-actor');
room.getPeers(); // Map<string, Peer>
room.getPeerCount(); // number
room.close();Room Events
| Event | Callback | Description |
|---|---|---|
| data | (data, from) | Data received |
| peer_joined | (PeerInfo) | Peer joined room |
| peer_left | (fingerprint) | Peer left room |
| peer_connected | (fingerprint) | P2P connection established with peer |
| closed | () | Room closed |
| error | (Error) | Error occurred |
E2EDirectRoom
End-to-end encrypted 1:1 room. Performs automatic ECDH key exchange and encrypts all messages with AES-GCM. Falls back to plaintext relay before keys are exchanged.
import { E2EDirectRoom } from 'peer-client';
const r1 = new E2EDirectRoom(client1, 'secure-room');
await r1.create();
const r2 = new E2EDirectRoom(client2, 'secure-room');
await r2.join();
r1.on('ready', (remoteFingerprint) => {
r1.send({ secret: 'hello' }); // encrypted
});
r1.on('data', (data, from) => {});
r1.on('decrypt_error', (from, raw) => {});
r1.on('state_changed', (state) => {});Constructor Options
new E2EDirectRoom(client, roomId, {
e2e: existingE2EInstance, // optional, reuse an E2E instance
roomFactory: (client, id) => room, // optional, custom DirectRoom factory
});Methods
| Method | Returns | Description |
|---|---|---|
| create() | Promise<void> | Create and host the room |
| join() | Promise<PeerInfo[]> | Join existing room |
| send(data) | void | Send data (encrypted if ready, plain otherwise) |
| close() | void | Close room |
| hasEncryption() | boolean | Whether encryption is active |
| getRoom() | DirectRoom \| null | Underlying DirectRoom |
| getE2E() | E2E | Underlying E2E instance |
Properties
| Property | Type | Description |
|---|---|---|
| state | 'connecting' \| 'exchanging' \| 'ready' \| 'closed' | Current room state |
| fingerprint | string | Remote peer's fingerprint |
Events
| Event | Callback | Description |
|---|---|---|
| ready | (remoteFingerprint) | Key exchange complete, encryption active |
| data | (data, from) | Decrypted or plain data received |
| decrypt_error | (from, raw) | Decryption failed; re-exchange triggered |
| peer_joined | (PeerInfo) | Remote peer joined |
| peer_left | (fingerprint) | Remote peer left |
| state_changed | (state) | Room state changed |
| closed | () | Room closed |
| error | (Error) | Error occurred |
Media
Audio/video calls with mute/unmute controls.
import { DirectMedia, GroupMedia } from 'peer-client';DirectMedia (1:1 Call)
const call = new DirectMedia(client, 'call-room');
const localStream = await call.createAndJoin({ audio: true, video: true });
call.on('remote_stream', (stream, from) => { videoEl.srcObject = stream; });
call.muteAudio();
call.unmuteAudio();
call.muteVideo();
call.unmuteVideo();
call.isAudioMuted(); // boolean
call.isVideoMuted(); // boolean
call.getLocalStream();
call.getRemoteStream();
call.close();GroupMedia (Conference)
const conf = new GroupMedia(client, 'conf-room');
const { stream, peers } = await conf.joinAndStart({ audio: true, video: true });
conf.on('remote_stream', (stream, fingerprint) => {});
conf.on('remote_stream_removed', (fingerprint) => {});
conf.getRemoteStreams(); // Map<string, MediaStream>
conf.getRemoteStream('fp-alice'); // MediaStream | undefined
conf.getPeerCount(); // number
conf.kick('fp-bad-actor');
conf.close();Media Events
| Event | Callback | Description |
|---|---|---|
| local_stream | (MediaStream) | Local stream acquired |
| remote_stream | (MediaStream, fingerprint) | Remote stream received |
| remote_stream_removed | (fingerprint) | Remote stream removed |
| muted | ('audio' \| 'video') | Track muted |
| unmuted | ('audio' \| 'video') | Track unmuted |
| error | (Error) | Error occurred |
| closed | () | Media session closed |
FileTransfer
Stream files over P2P data channels with backpressure control.
import { FileTransfer } from 'peer-client';
const ft = new FileTransfer(client);Sending
const peer = client.connectToPeer('fp-receiver');
peer.on('connected', async () => {
await ft.send(peer, file, 'report.pdf');
});
ft.on('progress', ({ id, percentage, bytesPerSecond }) => {});Receiving
ft.handleIncoming(peer);
ft.on('incoming', (meta, from) => {
ft.accept(meta.id); // or ft.reject(meta.id)
});
ft.on('complete', (id, blob, meta, from) => {
const url = URL.createObjectURL(blob);
});Methods
| Method | Returns | Description |
|---|---|---|
| send(peer, file, filename?) | Promise<string> | Send file, resolves with transfer id |
| handleIncoming(peer) | () => void | Listen for incoming transfers, returns cleanup |
| accept(id) | void | Accept incoming transfer |
| reject(id) | void | Reject incoming transfer |
| cancel(id) | void | Cancel active transfer |
| requestResume(id, lastIndex) | void | Request resume from chunk index |
| getReceiveProgress(id) | TransferProgress \| null | Get receive progress |
| destroy() | void | Clean up all listeners |
Events
| Event | Callback | Description |
|---|---|---|
| incoming | (FileMetadata, from) | Incoming transfer offer |
| progress | (TransferProgress) | Transfer progress update |
| complete | (id, Blob, FileMetadata, from) | Transfer completed |
| cancelled | (id) | Transfer cancelled |
| error | (error) | Transfer error |
JSONTransfer
Lightweight JSON messaging over relay or P2P.
import { JSONTransfer } from 'peer-client';
const jt = new JSONTransfer(client);
jt.sendToPeer('fp-bob', { hello: 'world' }); // P2P or relay fallback
jt.sendToRoom('room-id', { broadcast: true }); // broadcast to room
jt.onReceive(peer, (data, from) => {}); // direct P2P
jt.onRelayReceive((data, from) => {}); // relay messages
jt.onBroadcastReceive('room-id', (data, from) => {}); // broadcast messagesImageTransfer
Convenience wrapper around FileTransfer for images.
import { ImageTransfer } from 'peer-client';
const it = new ImageTransfer(client);
it.handleIncoming(peer);
it.on('incoming', (meta, from) => it.accept(meta.id));
it.on('complete', (id, blob, meta, from) => {});
await it.send(peer, imageBlob, 'photo.jpg');
it.destroy();StateSync
Distributed key-value state with Hybrid Logical Clocks (HLC).
import { StateSync } from 'peer-client';Last-Writer-Wins
const sync = new StateSync(client, 'room-1', { mode: 'lww' });
sync.start();
sync.set('score', 100);
sync.get('score'); // 100
sync.getAll(); // { score: 100 }
sync.delete('score');
sync.destroy();Operational (Custom Merge)
const sync = new StateSync(client, 'room-1', {
mode: 'operational',
merge: (local, remote) => [...new Set([...local, ...remote])],
});
sync.start();
sync.set('tags', ['a', 'b']);
sync.on('conflict', (key, local, remote, merged) => {});Methods
| Method | Returns | Description |
|---|---|---|
| start() | void | Start sync listeners |
| set(key, value) | void | Set a key-value pair |
| get(key) | any | Get value by key |
| getAll() | Record<string, any> | Get all non-deleted entries |
| getState() | Map<string, SyncState> | Get full internal state map |
| getHLC() | HLC | Get current HLC |
| delete(key) | void | Tombstone delete |
| loadState(entries) | void | Bulk load state entries |
| handleFullState(state, from) | void | Process a full state snapshot |
| requestFullState(fingerprint) | void | Request state from a peer |
| destroy() | void | Clean up |
Events
| Event | Callback | Description |
|---|---|---|
| state_changed | (key, value, from) | State changed |
| conflict | (key, local, remote, merged) | Merge conflict (operational mode) |
| synced | (from) | Synced with a peer |
| error | (Error) | Error occurred |
CRDTSync
Yjs CRDT integration for real-time collaborative editing.
import { CRDTSync } from 'peer-client';
import * as Y from 'yjs';
const crdt = new CRDTSync(client, 'collab-room', Y);
crdt.start();
const map = crdt.getMap('shared');
map.set('title', 'Hello');
const text = crdt.getText('doc');
text.insert(0, 'Hello world');
const arr = crdt.getArray('items');
arr.push(['item1']);
crdt.requestFullState('fp-peer');
crdt.destroy();Methods
| Method | Returns | Description |
|---|---|---|
| start() | void | Start CRDT sync |
| getDoc() | Y.Doc | Get Yjs document |
| getMap(name?) | Y.Map | Get shared map (default: 'shared') |
| getText(name?) | Y.Text | Get shared text (default: 'text') |
| getArray(name?) | Y.Array | Get shared array (default: 'array') |
| requestFullState(fingerprint) | void | Request full state from peer |
| destroy() | void | Clean up |
Events
| Event | Callback | Description |
|---|---|---|
| synced | (from) | Synced with a peer |
| error | (Error) | Error occurred |
OfflineSyncRoom
Offline-first key-value sync with IndexedDB persistence, HLC ordering, and optional E2E encryption. Automatically flushes pending operations when connectivity is restored.
import { OfflineSyncRoom } from 'peer-client';
const room = new OfflineSyncRoom(client, 'my-room', {
dbName: 'my-db', // default: 'osr-{roomId}'
encryptionEnabled: true, // default: true
maxPendingOps: 10000, // default: 10000
syncBatchSize: 50, // default: 50
conflictResolution: 'lww', // 'lww' | 'merge', default: 'lww'
merge: (local, remote) => ..., // required if conflictResolution is 'merge'
});
await room.init();
await room.createAndJoin(); // or room.joinExisting()
await room.set('key', 'value');
room.get('key'); // 'value'
room.has('key'); // true
room.keys(); // string[]
room.getAll(); // Record<string, any>
room.size; // number
await room.delete('key');
await room.pendingCount();
room.online; // boolean
room.ready; // boolean
room.syncing; // boolean
room.getE2E(); // E2E instance
await room.close();
await room.destroy(); // also deletes IndexedDBEvents
| Event | Callback | Description |
|---|---|---|
| ready | () | Store initialized |
| state_changed | (key, value, from) | Key changed |
| synced | (from) | Synced with a peer |
| sync_started | () | Flush started |
| sync_complete | () | Flush completed |
| online | () | Went online |
| offline | () | Went offline |
| peer_joined | (PeerJoinedInfo) | Peer joined |
| peer_left | (fingerprint) | Peer left |
| conflict | (key, local, remote, merged) | Merge conflict |
| error | (Error) | Error occurred |
| closed | () | Room closed |
E2E Encryption
ECDH key exchange with AES-GCM encryption.
import { GroupKeyManager, E2E } from 'peer-client';GroupKeyManager
const km = new GroupKeyManager(client);
await km.init();
await km.exchangeWith(peer);
const encrypted = await km.encryptForPeer('fp-bob', { secret: true });
peer.send({ _encrypted: true, data: encrypted });
peer.on('data', async (msg) => {
if (msg._encrypted) {
const decrypted = await km.decryptFromPeer(peer.fingerprint, msg.data);
}
});
km.destroy();Methods
| Method | Returns | Description |
|---|---|---|
| init() | Promise<void> | Generate ephemeral key pair |
| exchangeWith(peer) | Promise<void> | Initiate key exchange with peer |
| handleIncomingKeyExchange(peer, data) | Promise<void> | Handle incoming key exchange |
| encryptForPeer(fingerprint, data) | Promise<string> | Encrypt data for peer |
| decryptFromPeer(fingerprint, data) | Promise<any> | Decrypt data from peer |
| getE2E() | E2E | Get underlying E2E instance |
| destroy() | void | Clean up keys |
E2E (Low-Level)
const e2e = new E2E();
await e2e.init();
const pubKey = e2e.getPublicKeyB64();
const pubKeyRaw = e2e.getPublicKeyRaw();
await e2e.deriveKey('fp-bob', remotePubKeyB64);
const encrypted = await e2e.encrypt('fp-bob', 'secret');
const decrypted = await e2e.decrypt('fp-bob', encrypted);
e2e.hasKey('fp-bob'); // true
e2e.removeKey('fp-bob');
e2e.isInitialized(); // boolean
e2e.destroy();Identity
Persistent ECDSA identity for signing and fingerprinting.
import { Identity } from 'peer-client';
const id = new Identity();
await id.generate();
console.log(id.fingerprint);
const keys = await id.export();
localStorage.setItem('keys', JSON.stringify(keys));
const restored = new Identity();
await restored.restore(JSON.parse(localStorage.getItem('keys')!));
const client = new PeerClient({
url: 'wss://signal.example.com',
identityKeys: keys,
});Methods
| Method | Returns | Description |
|---|---|---|
| generate() | Promise<string> | Generate new key pair, returns public key B64 |
| restore(keys) | Promise<void> | Restore from exported keys |
| export() | Promise<IdentityKeys> | Export keys for persistence |
| setRegistered(fingerprint, alias) | void | Set server-assigned fingerprint |
| sign(data) | Promise<ArrayBuffer> | Sign data with private key |
| verify(publicKey, signature, data) | Promise<boolean> | Verify a signature |
| getPublicKey() | CryptoKey \| null | Get public CryptoKey |
| getPrivateKey() | CryptoKey \| null | Get private CryptoKey |
Emitter
All classes extend Emitter:
const off = emitter.on('event', (...args) => {}); // returns cleanup function
emitter.once('event', (...args) => {});
emitter.off('event', handler);
emitter.emit('event', ...args);
emitter.listenerCount('event');
emitter.removeAllListeners('event');
emitter.removeAllListeners();
import { setEmitterErrorHandler } from 'peer-client';
setEmitterErrorHandler((error, event) => {
console.error(`Error in ${event}:`, error);
});Types
import type {
ClientConfig,
PeerInfo,
PeerJoinedInfo,
MatchResult,
RoomConfig,
RoomCreatedResult,
RoomInfoResult,
MediaConfig,
DataChannelConfig,
FileMetadata,
TransferProgress,
TransferControl,
SyncConfig,
SyncMode,
SyncState,
HLC,
IdentityKeys,
OfflineSyncRoomConfig,
OfflineOperation,
} from 'peer-client';Key Types
interface PeerInfo {
fingerprint: string;
alias: string;
meta?: Record<string, any>;
app_type?: string;
}
interface PeerJoinedInfo extends PeerInfo {
namespace?: string;
}
interface MatchResult {
namespace: string;
session_id: string;
peers: PeerInfo[];
}
interface FileMetadata {
id: string;
filename: string;
size: number;
mime: string;
totalChunks: number;
chunkSize: number;
}
interface IdentityKeys {
fingerprint: string;
alias: string;
publicKeyB64: string;
privateKeyJwk: JsonWebKey;
publicKeyJwk: JsonWebKey;
}
interface HLC {
ts: number;
counter: number;
node: string;
}
type SyncMode = 'lww' | 'operational' | 'crdt';Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
| url | string | required | WebSocket signaling server URL |
| iceServers | RTCIceServer[] | Google STUN | ICE/TURN servers |
| alias | string | '' | Display name |
| meta | Record<string, any> | {} | Arbitrary metadata |
| autoReconnect | boolean | true | Auto-reconnect on disconnect |
| reconnectDelay | number | 1000 | Initial reconnect delay (ms) |
| reconnectMaxDelay | number | 30000 | Max reconnect delay (ms) |
| maxReconnectAttempts | number | Infinity | Max reconnect attempts |
| pingInterval | number | 25000 | WebSocket keepalive interval (ms) |
| identityKeys | IdentityKeys | auto-generated | Pre-existing identity keys |
License
MIT
