@colyseus/core
Version:
Multiplayer Framework for Node.js.
695 lines (694 loc) • 23.2 kB
JavaScript
// packages/core/src/MatchMaker.ts
import { EventEmitter } from "events";
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 "./presence/LocalPresence.mjs";
import { createScopedPresence } from "./presence/Presence.mjs";
import { debugAndPrintError, debugMatchMaking } from "./Debug.mjs";
import { SeatReservationError } from "./errors/SeatReservationError.mjs";
import { ServerError } from "./errors/ServerError.mjs";
import "./matchmaker/LocalDriver/LocalDriver.mjs";
import { controller } from "./matchmaker/controller.mjs";
import * as stats from "./Stats.mjs";
import { logger } from "./Logger.mjs";
import { getLockId, initializeRoomCache } from "./matchmaker/driver.mjs";
import { CloseCode, ErrorCode } from "@colyseus/shared-types";
import { getDefaultDriver, getDefaultPresence, getDefaultPublicAddress } from "./utils/Env.mjs";
var handlers = {};
var rooms = {};
var events = new EventEmitter();
var publicAddress;
var processId;
var presence;
var driver;
var selectProcessIdToCreateRoom = async function() {
return (await stats.fetchAll()).sort((p1, p2) => p1.roomCount > p2.roomCount ? 1 : -1)[0]?.processId || processId;
};
var enableHealthChecks = true;
function setHealthChecksEnabled(value) {
enableHealthChecks = value;
}
var onReady = new Deferred();
var MatchMakerState = {
INITIALIZING: 0,
READY: 1,
SHUTTING_DOWN: 2
};
var state;
async function setup(_presence, _driver, _publicAddress, _selectProcessIdToCreateRoom) {
if (onReady === void 0) {
onReady = new Deferred();
}
state = MatchMakerState.INITIALIZING;
presence = _presence || await getDefaultPresence();
driver = _driver || await getDefaultDriver();
publicAddress = _publicAddress || getDefaultPublicAddress();
stats.reset(false);
if (isDevMode) {
processId = await getPreviousProcessId();
}
if (!processId) {
processId = generateId();
}
if (_selectProcessIdToCreateRoom) {
selectProcessIdToCreateRoom = _selectProcessIdToCreateRoom;
}
if (driver.boot) {
await driver.boot();
}
onReady.resolve();
}
async function accept(isStandalone = false) {
await onReady;
if (isStandalone) {
state = MatchMakerState.READY;
return;
}
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 = MatchMakerState.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/room#allow-reconnection`);
}
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 buildSeatReservation(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/room#allow-reconnection`);
}
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 = `${String(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(klass, defaultOptions);
registeredHandler.name = roomName;
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 addRoomType(handler) {
handlers[handler.name] = handler;
}
function removeRoomType(roomName) {
delete handlers[roomName];
}
function getAllHandlers() {
return handlers;
}
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 === MatchMakerState.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 = createScopedPresence(room, presence);
room["_listing"] = initializeRoomCache({
name: roomName,
processId,
...handler.getMetadataFromOptions(clientOptions)
});
if (publicAddress) {
room["_listing"].publicAddress = publicAddress;
}
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("creating room '%s', roomId: '%s', processId: '%s'", roomName, room.roomId, processId);
stats.local.roomCount++;
stats.persist();
room["_events"].on("lock", lockRoom.bind(void 0, room));
room["_events"].on("unlock", unlockRoom.bind(void 0, room));
room["_events"].on("join", onClientJoinRoom.bind(void 0, room));
room["_events"].on("leave", onClientLeaveRoom.bind(void 0, room));
room["_events"].once("dispose", disposeRoom.bind(void 0, roomName, room));
if (handler.realtimeListingEnabled) {
room["_events"].on("visibility-change", onVisibilityChange.bind(void 0, room));
room["_events"].on("metadata-change", onMetadataChange.bind(void 0, room));
}
room["_events"].once("disconnect", () => {
room["_events"].removeAllListeners("lock");
room["_events"].removeAllListeners("unlock");
room["_events"].removeAllListeners("dispose");
if (handler.realtimeListingEnabled) {
room["_events"].removeAllListeners("visibility-change");
room["_events"].removeAllListeners("metadata-change");
}
if (stats.local.roomCount <= 0) {
events.emit("no-active-rooms");
}
});
await createRoomReferences(room, true);
if (state !== MatchMakerState.SHUTTING_DOWN) {
await driver.persist(room["_listing"], true);
}
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();
if (isDevMode) {
Room.prototype.onBeforeShutdown.call(room);
} else {
room.onBeforeShutdown();
}
}
await noActiveRooms;
}
async function gracefullyShutdown() {
if (state === MatchMakerState.SHUTTING_DOWN) {
return Promise.reject("already_shutting_down");
}
debugMatchMaking(`${processId} is shutting down!`);
state = MatchMakerState.SHUTTING_DOWN;
onReady = void 0;
if (isDevMode) {
for (const roomId in rooms) {
if (!rooms.hasOwnProperty(roomId)) {
continue;
}
rooms[roomId]["_rejectPendingReconnections"]?.("devmode_restart");
}
await new Promise((resolve) => setTimeout(resolve, 50));
await cacheRoomHistory(rooms);
}
await lockAndDisposeAll();
await removeRoomsByProcessId(processId);
presence.unsubscribe(getProcessChannel());
return Promise.all(disconnectAll(
isDevMode ? CloseCode.MAY_TRY_RECONNECT : CloseCode.SERVER_SHUTDOWN
));
}
async function hotReload() {
state = MatchMakerState.SHUTTING_DOWN;
for (const roomId in rooms) {
if (!rooms.hasOwnProperty(roomId)) {
continue;
}
rooms[roomId]["_rejectPendingReconnections"]?.("devmode_restart");
}
await new Promise((resolve) => setTimeout(resolve, 50));
await cacheRoomHistory(rooms);
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.prototype.onBeforeShutdown.call(room);
}
await noActiveRooms;
await Promise.all(disconnectAll(CloseCode.MAY_TRY_RECONNECT));
await removeRoomsByProcessId(processId);
state = MatchMakerState.READY;
await reloadFromCache();
await stats.persist();
}
async function reserveSeatFor(room, options, authData) {
const sessionId = authData?.sessionId || generateId();
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.`);
}
return buildSeatReservation(room, sessionId);
}
async function reserveMultipleSeatsFor(room, clientsData) {
let sessionIds = [];
let options = [];
let authData = [];
for (const clientData of clientsData) {
sessionIds.push(clientData.sessionId);
options.push(clientData.options);
authData.push(clientData.auth);
}
debugMatchMaking(
"reserving multiple seats. sessionIds: '%s', roomId: '%s', processId: '%s'",
sessionIds.join(", "),
room.roomId,
processId
);
let successfulSeatReservations;
try {
successfulSeatReservations = await remoteRoomCall(
room.roomId,
"_reserveMultipleSeats",
[sessionIds, 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 {
throw new SeatReservationError(`${room.roomId} is already full.`);
}
}
return successfulSeatReservations;
}
function buildSeatReservation(room, sessionId) {
const seatReservation = {
name: room.name,
sessionId,
roomId: room.roomId,
processId: room.processId
};
if (isDevMode) {
seatReservation.devMode = isDevMode;
}
if (room.publicAddress) {
seatReservation.publicAddress = room.publicAddress;
}
return seatReservation;
}
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 = (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.`);
await stats.excludeProcess(processId2);
if (!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);
}
function onMetadataChange(room) {
handlers[room.roomName].emit("metadata-change", room);
}
async function disposeRoom(roomName, room) {
debugMatchMaking("disposing '%s' (%s) on processId '%s' (graceful shutdown: %s)", roomName, room.roomId, processId, state === MatchMakerState.SHUTTING_DOWN);
driver.remove(room["_listing"].roomId);
stats.local.roomCount--;
if (state !== MatchMakerState.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,
addRoomType,
buildSeatReservation,
controller,
create,
createRoom,
defineRoomType,
disconnectAll,
driver,
findOneRoomAvailable,
getAllHandlers,
getHandler,
getLocalRoomById,
getRoomById,
getRoomClass,
gracefullyShutdown,
handleCreateRoom,
healthCheckAllProcesses,
healthCheckProcessId,
hotReload,
join,
joinById,
joinOrCreate,
onReady,
presence,
processId,
publicAddress,
query,
reconnect,
remoteRoomCall,
removeRoomType,
reserveMultipleSeatsFor,
reserveSeatFor,
selectProcessIdToCreateRoom,
setHealthChecksEnabled,
setup,
state,
stats
};