tinyroom
Version:
A minimalistic room/lobby system for multiplayer applications. Built on TinyPeer with automatic reconnection support.
325 lines • 11.2 kB
JavaScript
import { createDataPeer } from 'tinypeer';
const PING_TIMEOUT_MS = 5000;
const CONNECTION_CLOSE_DELAY_MS = 100;
const INTERNAL_EVENT = {
PING: '_ping',
PONG: '_pong',
ROOM_CLOSE: '_roomClose',
};
function createPingManager(peerId) {
const pendingPings = new Map();
function generatePingId() {
return `ping-${Math.random().toString(36).substring(2, 9)}-${Date.now()}`;
}
return {
handlePingRequest(pingId, conn) {
const pongEnvelope = {
_event: INTERNAL_EVENT.PONG,
_from: peerId,
};
conn.send({ pingId }, pongEnvelope).catch(() => {
// Ignore send errors
});
},
handlePongResponse(pingId) {
const pending = pendingPings.get(pingId);
if (pending) {
clearTimeout(pending.timeout);
const latency = Date.now() - pending.startTime;
pending.resolve(latency);
pendingPings.delete(pingId);
}
},
async sendPing(conn) {
const pingId = generatePingId();
const envelope = {
_event: INTERNAL_EVENT.PING,
_from: peerId,
};
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPings.delete(pingId);
reject(new Error('Ping timeout'));
}, PING_TIMEOUT_MS);
pendingPings.set(pingId, {
resolve,
reject,
timeout,
startTime: Date.now(),
});
conn.send({ pingId }, envelope).catch(error => {
clearTimeout(timeout);
pendingPings.delete(pingId);
reject(error);
});
});
},
};
}
function buildHostRoom(peer, connections, eventHandlers, peers, onHostClientSend) {
let peerJoinHandler = null;
let peerJoinHandlerSetOnce = false;
let peerLeaveHandler = null;
const pingManager = createPingManager(peer.id);
peer.on('connection', (conn) => {
connections.set(conn.peer, conn);
const newPeer = {
id: conn.peer,
metadata: conn.metadata,
};
peers.set(conn.peer, newPeer);
conn.on('data', (data, metadata) => {
const envelope = metadata;
if (envelope?._event) {
if (envelope._event === INTERNAL_EVENT.PONG) {
const { pingId } = data;
pingManager.handlePongResponse(pingId);
return;
}
if (envelope._event === INTERNAL_EVENT.PING) {
const { pingId } = data;
pingManager.handlePingRequest(pingId, conn);
return;
}
const handler = eventHandlers.get(envelope._event);
if (handler) {
handler(data, conn.peer, envelope._userMeta);
}
}
});
conn.on('close', () => {
connections.delete(conn.peer);
peers.delete(conn.peer);
if (peerLeaveHandler) {
peerLeaveHandler(conn.peer);
}
});
if (peerJoinHandler) {
peerJoinHandler({
...newPeer,
isHost: false,
close: (_reason) => {
conn.close();
},
});
}
});
return {
id: peer.id,
peers,
on(event, handler) {
eventHandlers.set(event, handler);
},
async broadcast(event, data, metadata) {
const envelope = {
_event: event,
_from: peer.id,
_userMeta: metadata,
};
const sends = Array.from(connections.values()).map(conn => conn.send(data, envelope));
await Promise.all(sends);
onHostClientSend(event, data, metadata);
},
async sendToPeer(event, data, target, metadata) {
const envelope = {
_event: event,
_from: peer.id,
_userMeta: metadata,
};
const targets = Array.isArray(target) ? target : [target];
const sends = targets
.map(peerId => connections.get(peerId))
.filter((conn) => conn !== undefined)
.map(conn => conn.send(data, envelope));
await Promise.all(sends);
},
async ping(peerId) {
const conn = connections.get(peerId);
if (!conn) {
throw new Error(`Peer ${peerId} not connected`);
}
return pingManager.sendPing(conn);
},
onPeerJoin(handler) {
peerJoinHandler = handler;
if (!peerJoinHandlerSetOnce) {
peerJoinHandlerSetOnce = true;
const hostPeer = peers.get(peer.id);
handler({
...hostPeer,
isHost: true,
close: (_reason) => {
throw new Error('Host cannot close themselves. Use room.close() to close the room.');
},
});
}
},
onPeerLeave(handler) {
peerLeaveHandler = handler;
},
async close() {
const envelope = {
_event: INTERNAL_EVENT.ROOM_CLOSE,
_from: peer.id,
};
const sends = Array.from(connections.values()).map(conn => conn.send('room closed', envelope));
await Promise.all(sends);
await new Promise(resolve => setTimeout(resolve, CONNECTION_CLOSE_DELAY_MS));
for (const conn of connections.values()) {
conn.close();
}
connections.clear();
peers.clear();
peer.destroy();
},
};
}
function buildClient(peerId, isHost, hostConnection, eventHandlers, onSend) {
let closeHandler = null;
let errorHandler = null;
const pingManager = createPingManager(peerId);
if (!isHost && hostConnection) {
hostConnection.on('data', (data, metadata) => {
const envelope = metadata;
if (envelope?._event) {
if (envelope._event === INTERNAL_EVENT.ROOM_CLOSE) {
if (closeHandler) {
closeHandler('room closed');
}
return;
}
if (envelope._event === INTERNAL_EVENT.PONG) {
const { pingId } = data;
pingManager.handlePongResponse(pingId);
return;
}
if (envelope._event === INTERNAL_EVENT.PING) {
const { pingId } = data;
pingManager.handlePingRequest(pingId, hostConnection);
return;
}
const handler = eventHandlers.get(envelope._event);
if (handler) {
handler(data, envelope._from || hostConnection.peer, envelope._userMeta);
}
}
});
hostConnection.on('close', () => {
if (closeHandler) {
closeHandler('connection closed');
}
});
hostConnection.on('error', (error) => {
if (errorHandler) {
errorHandler(error);
}
});
}
return {
id: peerId,
async send(event, data, metadata) {
if (isHost) {
await onSend(event, data, metadata);
}
else {
if (!hostConnection) {
throw new Error('Not connected to room');
}
const envelope = {
_event: event,
_broadcast: true,
_userMeta: metadata,
};
await hostConnection.send(data, envelope);
}
},
on(event, handler) {
eventHandlers.set(event, handler);
},
async ping() {
if (isHost) {
throw new Error('Host cannot ping itself. Use room.ping(peerId) instead');
}
if (!hostConnection) {
throw new Error('Not connected to room');
}
return pingManager.sendPing(hostConnection);
},
onClose(handler) {
closeHandler = handler;
},
onError(handler) {
errorHandler = handler;
},
leave() {
if (hostConnection) {
hostConnection.close();
}
},
};
}
export async function createRoom(roomId, options) {
const peer = await createDataPeer({ ...options, id: roomId });
const connections = new Map();
const roomEventHandlers = new Map();
const clientEventHandlers = new Map();
const peers = new Map();
// Add host to their own peers map
peers.set(peer.id, {
id: peer.id,
metadata: options?.metadata,
});
const onHostClientSend = (event, data, metadata) => {
const handler = roomEventHandlers.get(event);
if (handler) {
handler(data, peer.id, metadata);
}
};
const onRoomBroadcast = (event, data, metadata) => {
const handler = clientEventHandlers.get(event);
if (handler) {
handler(data, peer.id, metadata);
}
};
const room = buildHostRoom(peer, connections, roomEventHandlers, peers, onRoomBroadcast);
const client = buildClient(peer.id, true, null, clientEventHandlers, async (event, data, metadata) => {
onHostClientSend(event, data, metadata);
});
return { room, client };
}
export async function joinRoomInternal(roomId, options) {
const peer = await createDataPeer(options);
try {
const connection = await peer.connect(roomId, {
metadata: options?.metadata,
connectionTimeout: options?.connectionTimeout,
});
const eventHandlers = new Map();
const client = buildClient(peer.id, false, connection, eventHandlers, async () => {
throw new Error('Client should not call onSend directly');
});
return { client, connection };
}
catch (error) {
peer.destroy();
throw new Error(`Failed to join room: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function joinRoom(roomId, options) {
const { client } = await joinRoomInternal(roomId, options);
return client;
}
export async function joinOrCreateRoom(roomId, options) {
try {
const client = await joinRoom(roomId, {
...options,
connectionTimeout: options?.connectionTimeout || 1000,
});
return { client };
}
catch {
const result = await createRoom(roomId, options);
return result;
}
}
//# sourceMappingURL=room.js.map