tinyroom
Version:
A minimalistic room/lobby system for multiplayer applications. Built on TinyPeer with automatic reconnection support.
783 lines • 27.9 kB
JavaScript
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