UNPKG

@colyseus/core

Version:

Multiplayer Framework for Node.js.

591 lines (590 loc) 20.2 kB
// packages/core/src/MatchMaker.ts import { EventEmitter } from "events"; import { ErrorCode, Protocol } from "./Protocol.mjs"; import { requestFromIPC, subscribeIPC, subscribeWithTimeout } from "./IPC.mjs"; import { Deferred, generateId, merge, retry, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME, REMOTE_ROOM_SHORT_TIMEOUT } from "./utils/Utils.mjs"; import { isDevMode, cacheRoomHistory, getPreviousProcessId, getRoomRestoreListKey, reloadFromCache } from "./utils/DevMode.mjs"; import { RegisteredHandler } from "./matchmaker/RegisteredHandler.mjs"; import { Room, RoomInternalState } from "./Room.mjs"; import { LocalPresence } from "./presence/LocalPresence.mjs"; import { debugAndPrintError, debugMatchMaking } from "./Debug.mjs"; import { SeatReservationError } from "./errors/SeatReservationError.mjs"; import { ServerError } from "./errors/ServerError.mjs"; import { LocalDriver } from "./matchmaker/driver/local/LocalDriver.mjs"; import controller from "./matchmaker/controller.mjs"; import * as stats from "./Stats.mjs"; import { logger } from "./Logger.mjs"; import { getHostname } from "./discovery/index.mjs"; import { getLockId } from "./matchmaker/driver/api.mjs"; var handlers = {}; var rooms = {}; var events = new EventEmitter(); var publicAddress; var processId; var presence; var driver; var selectProcessIdToCreateRoom; var enableHealthChecks = true; function setHealthChecksEnabled(value) { enableHealthChecks = value; } var onReady = new Deferred(); var MatchMakerState = /* @__PURE__ */ ((MatchMakerState2) => { MatchMakerState2[MatchMakerState2["INITIALIZING"] = 0] = "INITIALIZING"; MatchMakerState2[MatchMakerState2["READY"] = 1] = "READY"; MatchMakerState2[MatchMakerState2["SHUTTING_DOWN"] = 2] = "SHUTTING_DOWN"; return MatchMakerState2; })(MatchMakerState || {}); var state; async function setup(_presence, _driver, _publicAddress, _selectProcessIdToCreateRoom) { if (onReady === void 0) { onReady = new Deferred(); } state = 0 /* INITIALIZING */; presence = _presence || new LocalPresence(); driver = _driver || new LocalDriver(); publicAddress = _publicAddress; stats.reset(false); if (isDevMode) { processId = await getPreviousProcessId(await getHostname()); } if (!processId) { processId = generateId(); } selectProcessIdToCreateRoom = _selectProcessIdToCreateRoom || async function() { return (await stats.fetchAll()).sort((p1, p2) => p1.roomCount > p2.roomCount ? 1 : -1)[0]?.processId || processId; }; onReady.resolve(); } async function accept() { await onReady; await subscribeIPC(presence, getProcessChannel(), (method, args) => { if (method === "healthcheck") { return true; } else { return handleCreateRoom.apply(void 0, args); } }); if (enableHealthChecks) { await healthCheckAllProcesses(); stats.setAutoPersistInterval(); } state = 1 /* READY */; await stats.persist(); if (isDevMode) { await reloadFromCache(); } } async function joinOrCreate(roomName, clientOptions = {}, authContext) { return await retry(async () => { const authData = await callOnAuth(roomName, clientOptions, authContext); let room = await findOneRoomAvailable(roomName, clientOptions); if (!room) { const handler = getHandler(roomName); const filterOptions = handler.getFilterOptions(clientOptions); const concurrencyKey = getLockId(filterOptions); await concurrentJoinOrCreateRoomLock(handler, concurrencyKey, async (roomId) => { if (roomId) { room = await driver.findOne({ roomId }); } if (!room || room.locked) { room = await findOneRoomAvailable(roomName, clientOptions); } if (!room) { room = await createRoom(roomName, clientOptions); presence.publish(`concurrent:${handler.name}:${concurrencyKey}`, room.roomId); } return room; }); } return await reserveSeatFor(room, clientOptions, authData); }, 5, [SeatReservationError]); } async function create(roomName, clientOptions = {}, authContext) { const authData = await callOnAuth(roomName, clientOptions, authContext); const room = await createRoom(roomName, clientOptions); return reserveSeatFor(room, clientOptions, authData); } async function join(roomName, clientOptions = {}, authContext) { return await retry(async () => { const authData = await callOnAuth(roomName, clientOptions, authContext); const room = await findOneRoomAvailable(roomName, clientOptions); if (!room) { throw new ServerError(ErrorCode.MATCHMAKE_INVALID_CRITERIA, `no rooms found with provided criteria`); } return reserveSeatFor(room, clientOptions, authData); }); } async function reconnect(roomId, clientOptions = {}) { const room = await driver.findOne({ roomId }); if (!room) { if (process.env.NODE_ENV !== "production") { logger.info(`\u274C room "${roomId}" has been disposed. Did you miss .allowReconnection()? \u{1F449} https://docs.colyseus.io/server/room/#allowreconnection-client-seconds`); } throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" has been disposed.`); } const reconnectionToken = clientOptions.reconnectionToken; if (!reconnectionToken) { throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, `'reconnectionToken' must be provided for reconnection.`); } const sessionId = await remoteRoomCall(room.roomId, "checkReconnectionToken", [reconnectionToken]); if (sessionId) { return { room, sessionId }; } else { if (process.env.NODE_ENV !== "production") { logger.info(`\u274C reconnection token invalid or expired. Did you miss .allowReconnection()? \u{1F449} https://docs.colyseus.io/server/room/#allowreconnection-client-seconds`); } throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, `reconnection token invalid or expired.`); } } async function joinById(roomId, clientOptions = {}, authContext) { const room = await driver.findOne({ roomId }); if (!room) { throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" not found`); } else if (room.locked) { throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" is locked`); } const authData = await callOnAuth(room.name, clientOptions, authContext); return reserveSeatFor(room, clientOptions, authData); } async function query(conditions = {}, sortOptions) { return await driver.query(conditions, sortOptions); } async function findOneRoomAvailable(roomName, filterOptions, additionalSortOptions) { const handler = getHandler(roomName); const sortOptions = Object.assign({}, handler.sortOptions ?? {}); if (additionalSortOptions) { Object.assign(sortOptions, additionalSortOptions); } return await driver.findOne({ locked: false, name: roomName, private: false, ...handler.getFilterOptions(filterOptions) }, sortOptions); } async function remoteRoomCall(roomId, method, args, rejectionTimeout = REMOTE_ROOM_SHORT_TIMEOUT) { const room = rooms[roomId]; if (!room) { try { return await requestFromIPC(presence, getRoomChannel(roomId), method, args, rejectionTimeout); } catch (e) { if (method === "_reserveSeat" && e.message === "ipc_timeout") { throw e; } const request = `${method}${args && " with args " + JSON.stringify(args) || ""}`; throw new ServerError( ErrorCode.MATCHMAKE_UNHANDLED, `remote room (${roomId}) timed out, requesting "${request}". (${rejectionTimeout}ms exceeded)` ); } } else { return !args && typeof room[method] !== "function" ? room[method] : await room[method].apply(room, args && JSON.parse(JSON.stringify(args))); } } function defineRoomType(roomName, klass, defaultOptions) { const registeredHandler = new RegisteredHandler(roomName, klass, defaultOptions); handlers[roomName] = registeredHandler; if (klass.prototype["onAuth"] !== Room.prototype["onAuth"]) { if (klass["onAuth"] !== Room["onAuth"]) { logger.info(`\u274C "${roomName}"'s onAuth() defined at the instance level will be ignored.`); } } return registeredHandler; } function removeRoomType(roomName) { delete handlers[roomName]; } function hasHandler(roomName) { logger.warn("hasHandler() is deprecated. Use getHandler() instead."); return handlers[roomName] !== void 0; } function getHandler(roomName) { const handler = handlers[roomName]; if (!handler) { throw new ServerError(ErrorCode.MATCHMAKE_NO_HANDLER, `provided room name "${roomName}" not defined`); } return handler; } function getRoomClass(roomName) { return handlers[roomName]?.klass; } async function createRoom(roomName, clientOptions) { const selectedProcessId = state === 1 /* READY */ ? await selectProcessIdToCreateRoom(roomName, clientOptions) : processId; let room; if (selectedProcessId === void 0) { if (isDevMode && processId === void 0) { await onReady; return createRoom(roomName, clientOptions); } else { throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, `no processId available to create room ${roomName}`); } } else if (selectedProcessId === processId) { room = await handleCreateRoom(roomName, clientOptions); } else { try { room = await requestFromIPC( presence, getProcessChannel(selectedProcessId), void 0, [roomName, clientOptions], REMOTE_ROOM_SHORT_TIMEOUT ); } catch (e) { if (e.message === "ipc_timeout") { debugAndPrintError(`${e.message}: create room request timed out for ${roomName} on processId ${selectedProcessId}.`); if (enableHealthChecks) { await stats.excludeProcess(selectedProcessId); } room = await handleCreateRoom(roomName, clientOptions); } else { throw e; } } } if (isDevMode) { presence.hset(getRoomRestoreListKey(), room.roomId, JSON.stringify({ "clientOptions": clientOptions, "roomName": roomName, "processId": processId })); } return room; } async function handleCreateRoom(roomName, clientOptions, restoringRoomId) { const handler = getHandler(roomName); const room = new handler.klass(); if (restoringRoomId && isDevMode) { room.roomId = restoringRoomId; } else { room.roomId = generateId(); } room["__init"](); room.roomName = roomName; room.presence = presence; const additionalListingData = handler.getFilterOptions(clientOptions); if (publicAddress) { additionalListingData.publicAddress = publicAddress; } room.listing = driver.createInstance({ name: roomName, processId, ...additionalListingData }); if (room.onCreate) { try { await room.onCreate(merge({}, clientOptions, handler.options)); } catch (e) { debugAndPrintError(e); throw new ServerError( e.code || ErrorCode.MATCHMAKE_UNHANDLED, e.message ); } } room["_internalState"] = RoomInternalState.CREATED; room.listing.roomId = room.roomId; room.listing.maxClients = room.maxClients; debugMatchMaking("spawning '%s', roomId: %s, processId: %s", roomName, room.roomId, processId); stats.local.roomCount++; stats.persist(); room._events.on("lock", lockRoom.bind(this, room)); room._events.on("unlock", unlockRoom.bind(this, room)); room._events.on("join", onClientJoinRoom.bind(this, room)); room._events.on("leave", onClientLeaveRoom.bind(this, room)); room._events.on("visibility-change", onVisibilityChange.bind(this, room)); room._events.once("dispose", disposeRoom.bind(this, roomName, room)); room._events.once("disconnect", () => { room._events.removeAllListeners("lock"); room._events.removeAllListeners("unlock"); room._events.removeAllListeners("visibility-change"); room._events.removeAllListeners("dispose"); if (stats.local.roomCount <= 0) { events.emit("no-active-rooms"); } }); await createRoomReferences(room, true); if (state !== 2 /* SHUTTING_DOWN */) { await room.listing.save(); } handler.emit("create", room); return room.listing; } function getRoomById(roomId) { return driver.findOne({ roomId }); } function getLocalRoomById(roomId) { return rooms[roomId]; } function disconnectAll(closeCode) { const promises = []; for (const roomId in rooms) { if (!rooms.hasOwnProperty(roomId)) { continue; } promises.push(rooms[roomId].disconnect(closeCode)); } return promises; } async function lockAndDisposeAll() { await stats.excludeProcess(processId); if (enableHealthChecks) { stats.clearAutoPersistInterval(); } const noActiveRooms = new Deferred(); if (stats.local.roomCount <= 0) { noActiveRooms.resolve(); } else { events.once("no-active-rooms", () => noActiveRooms.resolve()); } for (const roomId in rooms) { if (!rooms.hasOwnProperty(roomId)) { continue; } const room = rooms[roomId]; room.lock(); room.onBeforeShutdown(); } await noActiveRooms; } async function gracefullyShutdown() { if (state === 2 /* SHUTTING_DOWN */) { return Promise.reject("already_shutting_down"); } debugMatchMaking(`${processId} is shutting down!`); state = 2 /* SHUTTING_DOWN */; onReady = void 0; await lockAndDisposeAll(); if (isDevMode) { await cacheRoomHistory(rooms); } await removeRoomsByProcessId(processId); presence.unsubscribe(getProcessChannel()); return Promise.all(disconnectAll( isDevMode ? Protocol.WS_CLOSE_DEVMODE_RESTART : void 0 )); } async function reserveSeatFor(room, options, authData) { const sessionId = authData?.sessionId || generateId(); debugMatchMaking( "reserving seat. sessionId: '%s', roomId: '%s', processId: '%s'", sessionId, room.roomId, processId ); let successfulSeatReservation; try { successfulSeatReservation = await remoteRoomCall( room.roomId, "_reserveSeat", [sessionId, options, authData], REMOTE_ROOM_SHORT_TIMEOUT ); } catch (e) { debugMatchMaking(e); if (e.message === "ipc_timeout" && !(enableHealthChecks && await healthCheckProcessId(room.processId))) { throw new SeatReservationError(`process ${room.processId} is not available.`); } else { successfulSeatReservation = false; } } if (!successfulSeatReservation) { throw new SeatReservationError(`${room.roomId} is already full.`); } const response = { room, sessionId }; if (isDevMode) { response.devMode = isDevMode; } return response; } async function callOnAuth(roomName, clientOptions, authContext) { const roomClass = getRoomClass(roomName); if (roomClass && roomClass["onAuth"] && roomClass["onAuth"] !== Room["onAuth"]) { const result = await roomClass["onAuth"](authContext.token, clientOptions, authContext); if (!result) { throw new ServerError(ErrorCode.AUTH_FAILED, "onAuth failed"); } return result; } } async function healthCheckAllProcesses() { const allStats = await stats.fetchAll(); const activeProcessChannels = typeof presence.channels === "function" ? (await presence.channels("p:*")).map((c) => c.substring(2)) : []; if (allStats.length > 0) { await Promise.all( allStats.filter((stat) => stat.processId !== processId && // skip current process !activeProcessChannels.includes(stat.processId)).map((stat) => healthCheckProcessId(stat.processId)) ); } } var _healthCheckByProcessId = {}; function healthCheckProcessId(processId2) { if (_healthCheckByProcessId[processId2] !== void 0) { return _healthCheckByProcessId[processId2]; } _healthCheckByProcessId[processId2] = new Promise(async (resolve, reject) => { logger.debug(`> Performing health-check against processId: '${processId2}'...`); try { const requestTime = Date.now(); await requestFromIPC( presence, getProcessChannel(processId2), "healthcheck", [], REMOTE_ROOM_SHORT_TIMEOUT ); logger.debug(`\u2705 Process '${processId2}' successfully responded (${Date.now() - requestTime}ms)`); resolve(true); } catch (e) { logger.debug(`\u274C Process '${processId2}' failed to respond. Cleaning it up.`); const isProcessExcluded = await stats.excludeProcess(processId2); if (isProcessExcluded && !isDevMode) { await removeRoomsByProcessId(processId2); } resolve(false); } finally { delete _healthCheckByProcessId[processId2]; } }); return _healthCheckByProcessId[processId2]; } async function removeRoomsByProcessId(processId2) { await driver.cleanup(processId2); } async function createRoomReferences(room, init = false) { rooms[room.roomId] = room; if (init) { await subscribeIPC( presence, getRoomChannel(room.roomId), (method, args) => { return !args && typeof room[method] !== "function" ? room[method] : room[method].apply(room, args); } ); } return true; } async function concurrentJoinOrCreateRoomLock(handler, concurrencyKey, callback) { return new Promise(async (resolve, reject) => { const hkey = getConcurrencyHashKey(handler.name); const concurrency = await presence.hincrbyex( hkey, concurrencyKey, 1, // increment by 1 MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME * 2 // expire in 2x the time of MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME ) - 1; const fulfill = async (roomId) => { try { resolve(await callback(roomId)); } catch (e) { reject(e); } finally { await presence.hincrbyex(hkey, concurrencyKey, -1, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME * 2); } }; if (concurrency > 0) { debugMatchMaking( "receiving %d concurrent joinOrCreate for '%s' (%s)", concurrency, handler.name, concurrencyKey ); try { const roomId = await subscribeWithTimeout( presence, `concurrent:${handler.name}:${concurrencyKey}`, (MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME + Math.min(concurrency, 3) * 0.2) * 1e3 // convert to milliseconds ); return await fulfill(roomId); } catch (error) { } } return await fulfill(); }); } function onClientJoinRoom(room, client) { stats.local.ccu++; stats.persist(); handlers[room.roomName].emit("join", room, client); } function onClientLeaveRoom(room, client, willDispose) { stats.local.ccu--; stats.persist(); handlers[room.roomName].emit("leave", room, client, willDispose); } function lockRoom(room) { handlers[room.roomName].emit("lock", room); } async function unlockRoom(room) { if (await createRoomReferences(room)) { handlers[room.roomName].emit("unlock", room); } } function onVisibilityChange(room, isInvisible) { handlers[room.roomName].emit("visibility-change", room, isInvisible); } async function disposeRoom(roomName, room) { debugMatchMaking("disposing '%s' (%s) on processId '%s' (graceful shutdown: %s)", roomName, room.roomId, processId, state === 2 /* SHUTTING_DOWN */); room.listing.remove(); stats.local.roomCount--; if (state !== 2 /* SHUTTING_DOWN */) { stats.persist(); if (isDevMode) { await presence.hdel(getRoomRestoreListKey(), room.roomId); } } handlers[roomName].emit("dispose", room); presence.unsubscribe(getRoomChannel(room.roomId)); delete rooms[room.roomId]; } function getRoomChannel(roomId) { return `$${roomId}`; } function getConcurrencyHashKey(roomName) { return `ch:${roomName}`; } function getProcessChannel(id = processId) { return `p:${id}`; } export { MatchMakerState, accept, controller, create, createRoom, defineRoomType, disconnectAll, driver, findOneRoomAvailable, getHandler, getLocalRoomById, getRoomById, getRoomClass, gracefullyShutdown, handleCreateRoom, hasHandler, healthCheckAllProcesses, healthCheckProcessId, join, joinById, joinOrCreate, onReady, presence, processId, publicAddress, query, reconnect, remoteRoomCall, removeRoomType, reserveSeatFor, selectProcessIdToCreateRoom, setHealthChecksEnabled, setup, state, stats };