@colyseus/core
Version:
Multiplayer Framework for Node.js.
591 lines (590 loc) • 20.2 kB
JavaScript
// 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
};