UNPKG

tinyroom

Version:

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

783 lines 27.9 kB
import { createRoom as createBaseRoom, joinRoomInternal, } from './room.js'; const DEFAULT_RECONNECTION_OPTIONS = { timeout: 30000, maxRetries: 10, backoff: 1000, healthCheckInterval: 10000, healthCheckFailureThreshold: 2, }; function generateStableId() { return `stable-${crypto.randomUUID()}`; } function generateReconnectionToken() { return crypto.randomUUID(); } const STORAGE_KEYS = { currentRoom: 'tinyroom:current', session: (roomId) => `tinyroom:session:${roomId}`, }; function getFromStorage(key) { if (typeof sessionStorage === 'undefined') return null; try { return sessionStorage.getItem(key); } catch { return null; } } function saveToStorage(key, value) { if (typeof sessionStorage === 'undefined') return; try { sessionStorage.setItem(key, value); } catch { // sessionStorage not available or full } } function removeFromStorage(key) { if (typeof sessionStorage === 'undefined') return; try { sessionStorage.removeItem(key); } catch { // Ignore errors } } function saveCurrentRoomId(roomId) { saveToStorage(STORAGE_KEYS.currentRoom, roomId); } function clearCurrentRoomId() { removeFromStorage(STORAGE_KEYS.currentRoom); } export function getRecentSession() { const roomId = getFromStorage(STORAGE_KEYS.currentRoom); if (!roomId) return null; const session = loadSession(roomId); if (!session) return null; return session; } function saveSession(roomId, state) { const stateWithTimestamp = { ...state, timestamp: Date.now() }; saveToStorage(STORAGE_KEYS.session(roomId), JSON.stringify(stateWithTimestamp)); } function loadSession(roomId) { const data = getFromStorage(STORAGE_KEYS.session(roomId)); if (!data) return null; try { return JSON.parse(data); } catch { return null; } } export function clearSession(roomId) { console.log('Clearing session for room', roomId); removeFromStorage(STORAGE_KEYS.session(roomId)); } function createHostReconnectionState(hostStableId, hostPhysicalId, hostToken) { const state = { stableIdMap: new Map(), physicalToStableMap: new Map(), tokenMap: new Map(), disconnectedPeers: new Map(), intentionalLeaves: new Set(), }; state.stableIdMap.set(hostStableId, hostPhysicalId); state.physicalToStableMap.set(hostPhysicalId, hostStableId); state.tokenMap.set(hostStableId, hostToken); return state; } function createClientReconnectionState() { return { intentionalLeave: false, kickedByHost: false, roomClosing: false, isReconnecting: false, }; } function shouldAttemptReconnect(state) { return (!state.intentionalLeave && !state.kickedByHost && !state.roomClosing && !state.isReconnecting); } function updatePeerMapping(state, stableId, physicalId) { const oldPhysicalId = state.stableIdMap.get(stableId); if (oldPhysicalId && oldPhysicalId !== physicalId) { state.physicalToStableMap.delete(oldPhysicalId); } state.stableIdMap.set(stableId, physicalId); state.physicalToStableMap.set(physicalId, stableId); } function removePeerMapping(state, stableId) { const physicalId = state.stableIdMap.get(stableId); state.stableIdMap.delete(stableId); if (physicalId) { state.physicalToStableMap.delete(physicalId); } } function wrapClient(initialClient, roomId, options, reconnectionOptions, stableId, token, isHost, initialConnection, persistSession) { let currentClient = initialClient; let currentConnection = initialConnection; const state = createClientReconnectionState(); const eventHandlers = new Map(); let reconnectingHandler = null; let reconnectedHandler = null; let reconnectionFailedHandler = null; let userCloseHandler = null; let userErrorHandler = null; let clientHealth = null; function startClientHealthCheck() { if (!reconnectionOptions.healthCheckInterval) return; if (isHost) return; const timer = setInterval(async () => { try { await currentClient.ping(); if (clientHealth) { clientHealth.lastSuccessfulPing = Date.now(); clientHealth.consecutiveFailures = 0; } } catch { if (clientHealth) { clientHealth.consecutiveFailures++; if (clientHealth.consecutiveFailures >= reconnectionOptions.healthCheckFailureThreshold) { stopClientHealthCheck(); if (currentConnection) { currentConnection.close(); } } } } }, reconnectionOptions.healthCheckInterval); clientHealth = { lastSuccessfulPing: Date.now(), consecutiveFailures: 0, healthCheckTimer: timer, }; } function stopClientHealthCheck() { if (clientHealth?.healthCheckTimer) { clearInterval(clientHealth.healthCheckTimer); } clientHealth = null; } function setupInternalListeners(client) { client.on('_host_kicked', () => { state.kickedByHost = true; stopClientHealthCheck(); }); client.onClose(reason => { if (reason === 'room closed') { state.roomClosing = true; stopClientHealthCheck(); } if (userCloseHandler) { userCloseHandler(reason); } if (!isHost && reason === 'connection closed' && shouldAttemptReconnect(state)) { attemptReconnection(); } }); } async function attemptReconnection() { stopClientHealthCheck(); state.isReconnecting = true; let attempt = 0; while (attempt < reconnectionOptions.maxRetries) { attempt++; if (reconnectingHandler) { reconnectingHandler(attempt); } const delay = reconnectionOptions.backoff * Math.pow(2, attempt - 1); await new Promise(resolve => setTimeout(resolve, delay)); try { const metadata = { _stableId: stableId, _token: token }; const result = await joinRoomInternal(roomId, { ...options, metadata, connectionTimeout: 2000, }); currentClient = result.client; currentConnection = result.connection; setupInternalListeners(currentClient); rewireEventHandlers(currentClient); state.isReconnecting = false; startClientHealthCheck(); if (reconnectedHandler) { reconnectedHandler(); } return; } catch (error) { // Continue to next attempt } } state.isReconnecting = false; clearSession(roomId); clearCurrentRoomId(); if (reconnectionFailedHandler) { reconnectionFailedHandler(); } } function rewireEventHandlers(newClient) { for (const [event, handler] of eventHandlers) { if (!event.startsWith('_')) { newClient.on(event, handler); } } } setupInternalListeners(currentClient); if (!isHost && currentConnection) { currentClient.onError(error => { // Suppress data channel errors when room is closing if (state.roomClosing && error.message.includes('Data channel error')) { return; } if (userErrorHandler) { userErrorHandler(error); } }); } if (!isHost && currentConnection) { startClientHealthCheck(); } return { get id() { return stableId; }, async send(event, data, metadata) { if (state.isReconnecting && !isHost) { throw new Error('Cannot send while reconnecting'); } const enrichedMetadata = metadata ? { ...metadata, _stableId: stableId } : { _stableId: stableId }; await currentClient.send(event, data, enrichedMetadata); }, on(event, handler) { eventHandlers.set(event, handler); currentClient.on(event, handler); }, async ping() { if (state.isReconnecting && !isHost) { throw new Error('Cannot ping while reconnecting'); } return currentClient.ping(); }, onClose(handler) { userCloseHandler = handler; }, onError(handler) { userErrorHandler = handler; }, async leave(options) { stopClientHealthCheck(); const shouldPreserveSession = options?.preserveSession === true; state.intentionalLeave = true; if (!shouldPreserveSession) { await currentClient.send('_intentional_leave', { stableId }); await new Promise(resolve => setTimeout(resolve, 100)); } currentClient.leave(); if (persistSession && !shouldPreserveSession) { clearCurrentRoomId(); clearSession(roomId); } }, onReconnecting(handler) { reconnectingHandler = handler; }, onReconnected(handler) { reconnectedHandler = handler; }, onReconnectionFailed(handler) { reconnectionFailedHandler = handler; }, reconnectionState: { get isReconnecting() { return state.isReconnecting; }, get attempt() { return 0; }, get stableId() { return stableId; }, get token() { return token; }, }, clearReconnectionData() { if (!isHost) { clearSession(roomId); } }, }; } function wrapHostRoom(initialRoom, roomId, reconnectionOptions, stableId, token, persistSession, restoredState) { const state = createHostReconnectionState(stableId, initialRoom.id, token); const wrappedPeers = new Map(); const peerHandles = new Map(); const peerHealth = new Map(); function startHealthCheck(peerStableId) { if (!reconnectionOptions.healthCheckInterval || peerStableId === stableId) { return; } const physicalId = state.stableIdMap.get(peerStableId); if (!physicalId) { return; } const timer = setInterval(async () => { try { await initialRoom.ping(physicalId); const health = peerHealth.get(peerStableId); if (health) { health.lastSuccessfulPing = Date.now(); health.consecutiveFailures = 0; } } catch { const health = peerHealth.get(peerStableId); if (health) { health.consecutiveFailures++; if (health.consecutiveFailures >= reconnectionOptions.healthCheckFailureThreshold) { stopHealthCheck(peerStableId); const peerHandle = peerHandles.get(peerStableId); if (peerHandle) { peerHandle.close(); } } } } }, reconnectionOptions.healthCheckInterval); peerHealth.set(peerStableId, { lastSuccessfulPing: Date.now(), consecutiveFailures: 0, healthCheckTimer: timer, }); } function stopHealthCheck(peerStableId) { const health = peerHealth.get(peerStableId); if (health?.healthCheckTimer) { clearInterval(health.healthCheckTimer); } peerHealth.delete(peerStableId); } const hostPeer = initialRoom.peers.get(initialRoom.id); if (hostPeer) { wrappedPeers.set(stableId, hostPeer); } if (restoredState) { for (const peerStableId of restoredState.peerStableIds) { const metadata = restoredState.peerMetadata[peerStableId]; wrappedPeers.set(peerStableId, { id: peerStableId, metadata, }); } } function persistHostState() { if (!persistSession) return; const peerStableIds = Array.from(wrappedPeers.keys()).filter(id => id !== stableId); const peerMetadata = {}; for (const [peerId, peer] of wrappedPeers) { if (peerId !== stableId) { peerMetadata[peerId] = peer.metadata; } } saveSession(roomId, { role: 'host', roomId, stableId, peerStableIds, peerMetadata, timestamp: Date.now(), }); } let peerJoinHandlerSetOnce = false; let peerReconnectedHandlerSetOnce = false; let peerJoinHandler = null; let peerLeaveHandler = null; let peerDisconnectedHandler = null; let peerReconnectedHandler = null; let roomIsClosing = false; initialRoom.on('_intentional_leave', data => { const { stableId: peerStableId } = data; state.intentionalLeaves.add(peerStableId); }); initialRoom.onPeerJoin(peer => { if (peer.isHost) { return; } const metadata = peer.metadata; const providedStableId = metadata?._stableId; const providedToken = metadata?._token; const peerStableId = providedStableId || generateStableId(); const peerToken = providedToken || generateReconnectionToken(); const isPotentialReconnection = !!providedStableId; if (isPotentialReconnection) { const storedToken = state.tokenMap.get(peerStableId); if (storedToken && storedToken !== providedToken) { peer.close(); return; } const disconnectedPeer = state.disconnectedPeers.get(peerStableId); if (disconnectedPeer) { // Normal reconnection after temporary disconnect clearTimeout(disconnectedPeer.timeout); state.disconnectedPeers.delete(peerStableId); updatePeerMapping(state, peerStableId, peer.id); wrappedPeers.set(peerStableId, { id: peerStableId, metadata: disconnectedPeer.metadata, }); peerHandles.set(peerStableId, { close: peer.close }); startHealthCheck(peerStableId); if (peerReconnectedHandler) { peerReconnectedHandler({ id: peerStableId, metadata: disconnectedPeer.metadata, isHost: false, close: (reason, allowReconnect) => { if (allowReconnect) { peer.close(); return; } if (reason) { initialRoom .sendToPeer('_host_kicked', { reason }, peer.id) .then(() => new Promise(resolve => setTimeout(resolve, 50))) .then(() => peer.close()); } else { peer.close(); } }, }); } return; } else if (wrappedPeers.has(peerStableId)) { updatePeerMapping(state, peerStableId, peer.id); const existingPeer = wrappedPeers.get(peerStableId); wrappedPeers.set(peerStableId, { id: peerStableId, metadata: existingPeer.metadata, }); peerHandles.set(peerStableId, { close: peer.close }); startHealthCheck(peerStableId); if (peerReconnectedHandler) { peerReconnectedHandler({ id: peerStableId, metadata: existingPeer.metadata, isHost: false, close: (reason, allowReconnect) => { if (allowReconnect) { peer.close(); return; } if (reason) { initialRoom .sendToPeer('_host_kicked', { reason }, peer.id) .then(() => new Promise(resolve => setTimeout(resolve, 50))) .then(() => peer.close()); } else { peer.close(); } }, }); } return; } } state.tokenMap.set(peerStableId, peerToken); updatePeerMapping(state, peerStableId, peer.id); wrappedPeers.set(peerStableId, { id: peerStableId, metadata: peer.metadata, }); peerHandles.set(peerStableId, { close: peer.close }); startHealthCheck(peerStableId); persistHostState(); if (peerJoinHandler) { peerJoinHandler({ id: peerStableId, metadata: peer.metadata, isHost: false, close: (reason, allowReconnect) => { if (allowReconnect) { peer.close(); return; } if (reason) { initialRoom .sendToPeer('_host_kicked', { reason }, peer.id) .then(() => new Promise(resolve => setTimeout(resolve, 50))) .then(() => peer.close()); } else { peer.close(); } }, }); } }); initialRoom.onPeerLeave(physicalId => { const peerStableId = state.physicalToStableMap.get(physicalId); if (!peerStableId) { return; } if (roomIsClosing) { removePeerMapping(state, peerStableId); wrappedPeers.delete(peerStableId); stopHealthCheck(peerStableId); return; } if (state.intentionalLeaves.has(peerStableId)) { state.intentionalLeaves.delete(peerStableId); removePeerMapping(state, peerStableId); wrappedPeers.delete(peerStableId); stopHealthCheck(peerStableId); persistHostState(); if (peerLeaveHandler) { peerLeaveHandler(peerStableId, 'intentional'); } return; } const peerData = wrappedPeers.get(peerStableId); stopHealthCheck(peerStableId); if (peerDisconnectedHandler) { peerDisconnectedHandler(peerStableId); } const timeout = setTimeout(() => { state.disconnectedPeers.delete(peerStableId); removePeerMapping(state, peerStableId); wrappedPeers.delete(peerStableId); persistHostState(); if (peerLeaveHandler) { peerLeaveHandler(peerStableId, 'timeout'); } }, reconnectionOptions.timeout); state.disconnectedPeers.set(peerStableId, { physicalId, metadata: peerData?.metadata, timeout, disconnectedAt: Date.now(), }); }); return { get id() { return initialRoom.id; }, get peers() { return wrappedPeers; }, on(event, handler) { initialRoom.on(event, (data, fromId, metadata) => { const stableId = state.physicalToStableMap.get(fromId) || fromId; handler(data, stableId, metadata); }); }, async broadcast(event, data, metadata) { await initialRoom.broadcast(event, data, metadata); }, async sendToPeer(event, data, target, metadata) { const targets = Array.isArray(target) ? target : [target]; const physicalIds = targets .map(stableId => state.stableIdMap.get(stableId)) .filter((id) => id !== undefined); if (physicalIds.length > 0) { await initialRoom.sendToPeer(event, data, physicalIds, metadata); } }, async ping(peerId) { const physicalId = state.stableIdMap.get(peerId); if (!physicalId) { throw new Error(`Peer ${peerId} not connected`); } return initialRoom.ping(physicalId); }, onPeerJoin(handler) { peerJoinHandler = handler; if (!restoredState && !peerJoinHandlerSetOnce) { peerJoinHandlerSetOnce = true; handler({ id: stableId, metadata: hostPeer?.metadata, isHost: true, close: () => { throw new Error('Host cannot close themselves. Use room.close() to close the room.'); }, }); } }, onPeerLeave(handler) { if (peerLeaveHandler !== null) { throw new Error('onPeerLeave can only be called once'); } peerLeaveHandler = handler; }, onPeerDisconnected(handler) { peerDisconnectedHandler = handler; }, onPeerReconnected(handler) { peerReconnectedHandler = handler; if (restoredState && !peerReconnectedHandlerSetOnce) { peerReconnectedHandlerSetOnce = true; handler({ id: stableId, metadata: hostPeer?.metadata, isHost: true, close: () => { throw new Error('Host cannot close themselves. Use room.close() to close the room.'); }, }); } }, async close(options) { roomIsClosing = true; for (const peerStableId of peerHealth.keys()) { stopHealthCheck(peerStableId); } for (const [_stableId, disconnectedPeer] of state.disconnectedPeers) { clearTimeout(disconnectedPeer.timeout); } state.disconnectedPeers.clear(); await initialRoom.close(); if (persistSession && options?.preserveSession !== true) { clearSession(roomId); clearCurrentRoomId(); } }, reconnectionState: { get stableId() { return stableId; }, }, clearReconnectionData() { clearSession(roomId); }, }; } export async function resumeRoom(session, options) { if (session.role === 'host') { const result = await createRoom(session, options); return { room: result.room, client: result.client }; } else { const client = await joinRoom(session, options); return { client }; } } export async function createRoom(sessionOrRoomId, options) { const reconnectionOptions = { ...DEFAULT_RECONNECTION_OPTIONS, ...options?.reconnection, }; const persistSession = options?.persistSession ?? true; const isSession = typeof sessionOrRoomId === 'object'; const roomId = isSession ? sessionOrRoomId.roomId : sessionOrRoomId; const restoredState = isSession ? sessionOrRoomId : undefined; const hostStableId = roomId; const hostToken = generateReconnectionToken(); try { const { room: baseRoom, client: baseClient } = await createBaseRoom(roomId, options); const room = wrapHostRoom(baseRoom, roomId, reconnectionOptions, hostStableId, hostToken, persistSession, restoredState); const client = wrapClient(baseClient, roomId, options || {}, reconnectionOptions, hostStableId, hostToken, true, null, persistSession); if (persistSession) { saveCurrentRoomId(roomId); // Save initial host state so getRecentSession can detect the host role if (!restoredState) { saveSession(roomId, { role: 'host', roomId, stableId: hostStableId, peerStableIds: [], peerMetadata: {}, timestamp: Date.now(), }); } } return { room, client }; } catch (error) { // Failed to create/reclaim room clearSession(roomId); throw error; } } export async function joinRoom(sessionOrRoomId, options) { const reconnectionOptions = { ...DEFAULT_RECONNECTION_OPTIONS, ...options?.reconnection, }; const persistSession = options?.persistSession ?? true; const isSession = typeof sessionOrRoomId === 'object'; const roomId = isSession ? sessionOrRoomId.roomId : sessionOrRoomId; const clientStableId = isSession ? sessionOrRoomId.stableId : generateStableId(); const clientToken = isSession ? sessionOrRoomId.token : generateReconnectionToken(); const metadata = { ...options?.metadata, _stableId: clientStableId, _token: clientToken, }; const { client: baseClient, connection } = await joinRoomInternal(roomId, { ...options, metadata, }); const client = wrapClient(baseClient, roomId, options || {}, reconnectionOptions, clientStableId, clientToken, false, connection, persistSession); if (persistSession) { saveCurrentRoomId(roomId); saveSession(roomId, { role: 'client', roomId, stableId: clientStableId, token: clientToken, timestamp: Date.now(), }); } return client; } export async function joinOrCreateRoom(sessionOrRoomId, options) { if (typeof sessionOrRoomId === 'object') { return await resumeRoom(sessionOrRoomId, options); } const roomId = sessionOrRoomId; try { const client = await joinRoom(roomId, { ...options, connectionTimeout: 1000, }); return { client }; } catch { // Join failed - room doesn't exist // Clear any client keys that were saved during the failed join attempt const persistSession = options?.persistSession ?? true; if (persistSession) { clearSession(roomId); } const result = await createRoom(roomId, options); return result; } } //# sourceMappingURL=reconnect.js.map