UNPKG

tinyroom

Version:

A minimalistic room/lobby system for multiplayer applications. Built on TinyPeer with automatic reconnection support.

325 lines 11.2 kB
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