@colyseus/core
Version:
Multiplayer Framework for Node.js.
657 lines (656 loc) • 24.3 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var MatchMaker_exports = {};
__export(MatchMaker_exports, {
MatchMakerState: () => MatchMakerState,
accept: () => accept,
controller: () => import_controller.default,
create: () => create,
createRoom: () => createRoom,
defineRoomType: () => defineRoomType,
disconnectAll: () => disconnectAll,
driver: () => driver,
findOneRoomAvailable: () => findOneRoomAvailable,
getHandler: () => getHandler,
getLocalRoomById: () => getLocalRoomById,
getRoomById: () => getRoomById,
getRoomClass: () => getRoomClass,
gracefullyShutdown: () => gracefullyShutdown,
handleCreateRoom: () => handleCreateRoom,
hasHandler: () => hasHandler,
healthCheckAllProcesses: () => healthCheckAllProcesses,
healthCheckProcessId: () => healthCheckProcessId,
join: () => join,
joinById: () => joinById,
joinOrCreate: () => joinOrCreate,
onReady: () => onReady,
presence: () => presence,
processId: () => processId,
publicAddress: () => publicAddress,
query: () => query,
reconnect: () => reconnect,
remoteRoomCall: () => remoteRoomCall,
removeRoomType: () => removeRoomType,
reserveSeatFor: () => reserveSeatFor,
selectProcessIdToCreateRoom: () => selectProcessIdToCreateRoom,
setHealthChecksEnabled: () => setHealthChecksEnabled,
setup: () => setup,
state: () => state,
stats: () => stats
});
module.exports = __toCommonJS(MatchMaker_exports);
var import_events = require("events");
var import_Protocol = require("./Protocol.js");
var import_IPC = require("./IPC.js");
var import_Utils = require("./utils/Utils.js");
var import_DevMode = require("./utils/DevMode.js");
var import_RegisteredHandler = require("./matchmaker/RegisteredHandler.js");
var import_Room = require("./Room.js");
var import_LocalPresence = require("./presence/LocalPresence.js");
var import_Debug = require("./Debug.js");
var import_SeatReservationError = require("./errors/SeatReservationError.js");
var import_ServerError = require("./errors/ServerError.js");
var import_LocalDriver = require("./matchmaker/driver/local/LocalDriver.js");
var import_controller = __toESM(require("./matchmaker/controller.js"));
var stats = __toESM(require("./Stats.js"));
var import_Logger = require("./Logger.js");
var import_discovery = require("./discovery/index.js");
var import_api = require("./matchmaker/driver/api.js");
const handlers = {};
const rooms = {};
const events = new import_events.EventEmitter();
let publicAddress;
let processId;
let presence;
let driver;
let selectProcessIdToCreateRoom;
let enableHealthChecks = true;
function setHealthChecksEnabled(value) {
enableHealthChecks = value;
}
let onReady = new import_Utils.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 || {});
let state;
async function setup(_presence, _driver, _publicAddress, _selectProcessIdToCreateRoom) {
if (onReady === void 0) {
onReady = new import_Utils.Deferred();
}
state = 0 /* INITIALIZING */;
presence = _presence || new import_LocalPresence.LocalPresence();
driver = _driver || new import_LocalDriver.LocalDriver();
publicAddress = _publicAddress;
stats.reset(false);
if (import_DevMode.isDevMode) {
processId = await (0, import_DevMode.getPreviousProcessId)(await (0, import_discovery.getHostname)());
}
if (!processId) {
processId = (0, import_Utils.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 (0, import_IPC.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 (import_DevMode.isDevMode) {
await (0, import_DevMode.reloadFromCache)();
}
}
async function joinOrCreate(roomName, clientOptions = {}, authContext) {
return await (0, import_Utils.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 = (0, import_api.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, [import_SeatReservationError.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 (0, import_Utils.retry)(async () => {
const authData = await callOnAuth(roomName, clientOptions, authContext);
const room = await findOneRoomAvailable(roomName, clientOptions);
if (!room) {
throw new import_ServerError.ServerError(import_Protocol.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") {
import_Logger.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 import_ServerError.ServerError(import_Protocol.ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" has been disposed.`);
}
const reconnectionToken = clientOptions.reconnectionToken;
if (!reconnectionToken) {
throw new import_ServerError.ServerError(import_Protocol.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") {
import_Logger.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 import_ServerError.ServerError(import_Protocol.ErrorCode.MATCHMAKE_EXPIRED, `reconnection token invalid or expired.`);
}
}
async function joinById(roomId, clientOptions = {}, authContext) {
const room = await driver.findOne({ roomId });
if (!room) {
throw new import_ServerError.ServerError(import_Protocol.ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" not found`);
} else if (room.locked) {
throw new import_ServerError.ServerError(import_Protocol.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 = import_Utils.REMOTE_ROOM_SHORT_TIMEOUT) {
const room = rooms[roomId];
if (!room) {
try {
return await (0, import_IPC.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 import_ServerError.ServerError(
import_Protocol.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 import_RegisteredHandler.RegisteredHandler(roomName, klass, defaultOptions);
handlers[roomName] = registeredHandler;
if (klass.prototype["onAuth"] !== import_Room.Room.prototype["onAuth"]) {
if (klass["onAuth"] !== import_Room.Room["onAuth"]) {
import_Logger.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) {
import_Logger.logger.warn("hasHandler() is deprecated. Use getHandler() instead.");
return handlers[roomName] !== void 0;
}
function getHandler(roomName) {
const handler = handlers[roomName];
if (!handler) {
throw new import_ServerError.ServerError(import_Protocol.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 (import_DevMode.isDevMode && processId === void 0) {
await onReady;
return createRoom(roomName, clientOptions);
} else {
throw new import_ServerError.ServerError(import_Protocol.ErrorCode.MATCHMAKE_UNHANDLED, `no processId available to create room ${roomName}`);
}
} else if (selectedProcessId === processId) {
room = await handleCreateRoom(roomName, clientOptions);
} else {
try {
room = await (0, import_IPC.requestFromIPC)(
presence,
getProcessChannel(selectedProcessId),
void 0,
[roomName, clientOptions],
import_Utils.REMOTE_ROOM_SHORT_TIMEOUT
);
} catch (e) {
if (e.message === "ipc_timeout") {
(0, import_Debug.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 (import_DevMode.isDevMode) {
presence.hset((0, import_DevMode.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 && import_DevMode.isDevMode) {
room.roomId = restoringRoomId;
} else {
room.roomId = (0, import_Utils.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((0, import_Utils.merge)({}, clientOptions, handler.options));
} catch (e) {
(0, import_Debug.debugAndPrintError)(e);
throw new import_ServerError.ServerError(
e.code || import_Protocol.ErrorCode.MATCHMAKE_UNHANDLED,
e.message
);
}
}
room["_internalState"] = import_Room.RoomInternalState.CREATED;
room.listing.roomId = room.roomId;
room.listing.maxClients = room.maxClients;
(0, import_Debug.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 import_Utils.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");
}
(0, import_Debug.debugMatchMaking)(`${processId} is shutting down!`);
state = 2 /* SHUTTING_DOWN */;
onReady = void 0;
await lockAndDisposeAll();
if (import_DevMode.isDevMode) {
await (0, import_DevMode.cacheRoomHistory)(rooms);
}
await removeRoomsByProcessId(processId);
presence.unsubscribe(getProcessChannel());
return Promise.all(disconnectAll(
import_DevMode.isDevMode ? import_Protocol.Protocol.WS_CLOSE_DEVMODE_RESTART : void 0
));
}
async function reserveSeatFor(room, options, authData) {
const sessionId = authData?.sessionId || (0, import_Utils.generateId)();
(0, import_Debug.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],
import_Utils.REMOTE_ROOM_SHORT_TIMEOUT
);
} catch (e) {
(0, import_Debug.debugMatchMaking)(e);
if (e.message === "ipc_timeout" && !(enableHealthChecks && await healthCheckProcessId(room.processId))) {
throw new import_SeatReservationError.SeatReservationError(`process ${room.processId} is not available.`);
} else {
successfulSeatReservation = false;
}
}
if (!successfulSeatReservation) {
throw new import_SeatReservationError.SeatReservationError(`${room.roomId} is already full.`);
}
const response = { room, sessionId };
if (import_DevMode.isDevMode) {
response.devMode = import_DevMode.isDevMode;
}
return response;
}
async function callOnAuth(roomName, clientOptions, authContext) {
const roomClass = getRoomClass(roomName);
if (roomClass && roomClass["onAuth"] && roomClass["onAuth"] !== import_Room.Room["onAuth"]) {
const result = await roomClass["onAuth"](authContext.token, clientOptions, authContext);
if (!result) {
throw new import_ServerError.ServerError(import_Protocol.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))
);
}
}
const _healthCheckByProcessId = {};
function healthCheckProcessId(processId2) {
if (_healthCheckByProcessId[processId2] !== void 0) {
return _healthCheckByProcessId[processId2];
}
_healthCheckByProcessId[processId2] = new Promise(async (resolve, reject) => {
import_Logger.logger.debug(`> Performing health-check against processId: '${processId2}'...`);
try {
const requestTime = Date.now();
await (0, import_IPC.requestFromIPC)(
presence,
getProcessChannel(processId2),
"healthcheck",
[],
import_Utils.REMOTE_ROOM_SHORT_TIMEOUT
);
import_Logger.logger.debug(`\u2705 Process '${processId2}' successfully responded (${Date.now() - requestTime}ms)`);
resolve(true);
} catch (e) {
import_Logger.logger.debug(`\u274C Process '${processId2}' failed to respond. Cleaning it up.`);
const isProcessExcluded = await stats.excludeProcess(processId2);
if (isProcessExcluded && !import_DevMode.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 (0, import_IPC.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
import_Utils.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, import_Utils.MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME * 2);
}
};
if (concurrency > 0) {
(0, import_Debug.debugMatchMaking)(
"receiving %d concurrent joinOrCreate for '%s' (%s)",
concurrency,
handler.name,
concurrencyKey
);
try {
const roomId = await (0, import_IPC.subscribeWithTimeout)(
presence,
`concurrent:${handler.name}:${concurrencyKey}`,
(import_Utils.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) {
(0, import_Debug.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 (import_DevMode.isDevMode) {
await presence.hdel((0, import_DevMode.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}`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
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
});