@colyseus/core
Version:
Multiplayer Framework for Node.js.
840 lines (839 loc) • 31.3 kB
JavaScript
// packages/core/src/Room.ts
import { unpack } from "@colyseus/msgpackr";
import { decode, $changes } from "@colyseus/schema";
import Clock from "@colyseus/timer";
import { EventEmitter } from "events";
import { logger } from "./Logger.mjs";
import { NoneSerializer } from "./serializer/NoneSerializer.mjs";
import { SchemaSerializer } from "./serializer/SchemaSerializer.mjs";
import { ErrorCode, getMessageBytes, Protocol } from "./Protocol.mjs";
import { Deferred, generateId, wrapTryCatch } from "./utils/Utils.mjs";
import { isDevMode } from "./utils/DevMode.mjs";
import { debugAndPrintError, debugMatchMaking, debugMessage } from "./Debug.mjs";
import { ServerError } from "./errors/ServerError.mjs";
import { ClientArray, ClientState } from "./Transport.mjs";
import { OnAuthException, OnCreateException, OnDisposeException, OnJoinException, OnLeaveException, OnMessageException, SimulationIntervalException, TimedEventException } from "./errors/RoomExceptions.mjs";
var DEFAULT_PATCH_RATE = 1e3 / 20;
var DEFAULT_SIMULATION_INTERVAL = 1e3 / 60;
var noneSerializer = new NoneSerializer();
var DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
var RoomInternalState = /* @__PURE__ */ ((RoomInternalState2) => {
RoomInternalState2[RoomInternalState2["CREATING"] = 0] = "CREATING";
RoomInternalState2[RoomInternalState2["CREATED"] = 1] = "CREATED";
RoomInternalState2[RoomInternalState2["DISPOSING"] = 2] = "DISPOSING";
return RoomInternalState2;
})(RoomInternalState || {});
var Room = class _Room {
constructor() {
/**
* Timing events tied to the room instance.
* Intervals and timeouts are cleared when the room is disposed.
*/
this.clock = new Clock();
this.#_onLeaveConcurrent = 0;
// number of onLeave calls in progress
/**
* Maximum number of clients allowed to connect into the room. When room reaches this limit,
* it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,
* the room will be unlocked as soon as a client disconnects from it.
*/
this.maxClients = Infinity;
this.#_maxClientsReached = false;
/**
* Automatically dispose the room when last client disconnects.
*
* @default true
*/
this.autoDispose = true;
/**
* Frequency to send the room state to connected clients, in milliseconds.
*
* @default 50ms (20fps)
*/
this.patchRate = DEFAULT_PATCH_RATE;
/**
* The array of connected clients.
*
* @see {@link https://docs.colyseus.io/colyseus/server/room/#client|Client instance}
*/
this.clients = new ClientArray();
/** @internal */
this._events = new EventEmitter();
// seat reservation & reconnection
this.seatReservationTime = DEFAULT_SEAT_RESERVATION_TIME;
this.reservedSeats = {};
this.reservedSeatTimeouts = {};
this._reconnections = {};
this._reconnectingSessionId = /* @__PURE__ */ new Map();
this.onMessageHandlers = {
"__no_message_handler": {
callback: (client, messageType, _) => {
const errorMessage = `room onMessage for "${messageType}" not registered.`;
debugAndPrintError(`${errorMessage} (roomId: ${this.roomId})`);
if (isDevMode) {
client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
} else {
client.leave(Protocol.WS_CLOSE_WITH_ERROR, errorMessage);
}
}
}
};
this._serializer = noneSerializer;
this._afterNextPatchQueue = [];
this._internalState = 0 /* CREATING */;
this._lockedExplicitly = false;
this.#_locked = false;
this._events.once("dispose", () => {
this._dispose().catch((e) => debugAndPrintError(`onDispose error: ${e && e.stack || e.message || e || "promise rejected"} (roomId: ${this.roomId})`)).finally(() => this._events.emit("disconnect"));
});
if (this.onUncaughtException !== void 0) {
this.#registerUncaughtExceptionHandlers();
}
}
/**
* This property will change on these situations:
* - The maximum number of allowed clients has been reached (`maxClients`)
* - You manually locked, or unlocked the room using lock() or `unlock()`.
*
* @readonly
*/
get locked() {
return this.#_locked;
}
get metadata() {
return this.listing.metadata;
}
#_roomId;
#_roomName;
#_onLeaveConcurrent;
#_maxClientsReached;
#_maxClients;
#_autoDispose;
#_patchRate;
#_patchInterval;
#_state;
#_locked;
/**
* This method is called by the MatchMaker before onCreate()
* @internal
*/
__init() {
this.#_state = this.state;
this.#_autoDispose = this.autoDispose;
this.#_patchRate = this.patchRate;
this.#_maxClients = this.maxClients;
Object.defineProperties(this, {
state: {
enumerable: true,
get: () => this.#_state,
set: (newState) => {
if (newState?.constructor[Symbol.metadata] !== void 0 || newState[$changes] !== void 0) {
this.setSerializer(new SchemaSerializer());
} else if ("_definition" in newState) {
throw new Error("@colyseus/schema v2 compatibility currently missing (reach out if you need it)");
} else if ($changes === void 0) {
throw new Error("Multiple @colyseus/schema versions detected. Please make sure you don't have multiple versions of @colyseus/schema installed.");
}
this._serializer.reset(newState);
this.#_state = newState;
}
},
maxClients: {
enumerable: true,
get: () => this.#_maxClients,
set: (value) => {
this.#_maxClients = value;
if (this._internalState === 1 /* CREATED */) {
const hasReachedMaxClients = this.hasReachedMaxClients();
if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {
this.#_maxClientsReached = false;
this.#_locked = false;
this.listing.locked = false;
}
if (hasReachedMaxClients) {
this.#_maxClientsReached = true;
this.#_locked = true;
this.listing.locked = true;
}
this.listing.maxClients = value;
this.listing.save();
}
}
},
autoDispose: {
enumerable: true,
get: () => this.#_autoDispose,
set: (value) => {
if (value !== this.#_autoDispose && this._internalState !== 2 /* DISPOSING */) {
this.#_autoDispose = value;
this.resetAutoDisposeTimeout();
}
}
},
patchRate: {
enumerable: true,
get: () => this.#_patchRate,
set: (milliseconds) => {
this.#_patchRate = milliseconds;
if (this.#_patchInterval) {
clearInterval(this.#_patchInterval);
this.#_patchInterval = void 0;
}
if (milliseconds !== null && milliseconds !== 0) {
this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds);
}
}
}
});
this.patchRate = this.#_patchRate;
if (this.#_state) {
this.state = this.#_state;
}
this.resetAutoDisposeTimeout(this.seatReservationTime);
this.clock.start();
}
/**
* The name of the room you provided as first argument for `gameServer.define()`.
*
* @returns roomName string
*/
get roomName() {
return this.#_roomName;
}
/**
* Setting the name of the room. Overwriting this property is restricted.
*
* @param roomName
*/
set roomName(roomName) {
if (this.#_roomName) {
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomName' cannot be overwritten.");
}
this.#_roomName = roomName;
}
/**
* A unique, auto-generated, 9-character-long id of the room.
* You may replace `this.roomId` during `onCreate()`.
*
* @returns roomId string
*/
get roomId() {
return this.#_roomId;
}
/**
* Setting the roomId, is restricted in room lifetime except upon room creation.
*
* @param roomId
* @returns roomId string
*/
set roomId(roomId) {
if (this._internalState !== 0 /* CREATING */ && !isDevMode) {
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation.");
}
this.#_roomId = roomId;
}
onAuth(client, options, context) {
return true;
}
static async onAuth(token, options, context) {
return true;
}
/**
* This method is called during graceful shutdown of the server process
* You may override this method to dispose the room in your own way.
*
* Once process reaches room count of 0, the room process will be terminated.
*/
onBeforeShutdown() {
this.disconnect(
isDevMode ? Protocol.WS_CLOSE_DEVMODE_RESTART : Protocol.WS_CLOSE_CONSENTED
);
}
/**
* Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients.
*
* @returns boolean
*/
hasReachedMaxClients() {
return this.clients.length + Object.keys(this.reservedSeats).length >= this.maxClients || this._internalState === 2 /* DISPOSING */;
}
/**
* Set the number of seconds a room can wait for a client to effectively join the room.
* You should consider how long your `onAuth()` will have to wait for setting a different seat reservation time.
* The default value is 15 seconds. You may set the `COLYSEUS_SEAT_RESERVATION_TIME`
* environment variable if you'd like to change the seat reservation time globally.
*
* @default 15 seconds
*
* @param seconds - number of seconds.
* @returns The modified Room object.
*/
setSeatReservationTime(seconds) {
this.seatReservationTime = seconds;
return this;
}
hasReservedSeat(sessionId, reconnectionToken) {
const reservedSeat = this.reservedSeats[sessionId];
if (reservedSeat === void 0) {
return false;
}
if (reservedSeat[3]) {
return reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId && this._reconnectingSessionId.has(sessionId);
} else {
return reservedSeat[2] === false;
}
}
checkReconnectionToken(reconnectionToken) {
const sessionId = this._reconnections[reconnectionToken]?.[0];
const reservedSeat = this.reservedSeats[sessionId];
if (reservedSeat && reservedSeat[3]) {
this._reconnectingSessionId.set(sessionId, reconnectionToken);
return sessionId;
} else {
return void 0;
}
}
/**
* (Optional) Set a simulation interval that can change the state of the game.
* The simulation interval is your game loop.
*
* @default 16.6ms (60fps)
*
* @param onTickCallback - You can implement your physics or world updates here!
* This is a good place to update the room state.
* @param delay - Interval delay on executing `onTickCallback` in milliseconds.
*/
setSimulationInterval(onTickCallback, delay = DEFAULT_SIMULATION_INTERVAL) {
if (this._simulationInterval) {
clearInterval(this._simulationInterval);
}
if (onTickCallback) {
if (this.onUncaughtException !== void 0) {
onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, "setSimulationInterval");
}
this._simulationInterval = setInterval(() => {
this.clock.tick();
onTickCallback(this.clock.deltaTime);
}, delay);
}
}
/**
* @deprecated Use `.patchRate=` instead.
*/
setPatchRate(milliseconds) {
this.patchRate = milliseconds;
}
/**
* @deprecated Use `.state =` instead.
*/
setState(newState) {
this.state = newState;
}
setSerializer(serializer) {
this._serializer = serializer;
}
async setMetadata(meta) {
if (!this.listing.metadata) {
this.listing.metadata = meta;
} else {
for (const field in meta) {
if (!meta.hasOwnProperty(field)) {
continue;
}
this.listing.metadata[field] = meta[field];
}
if ("markModified" in this.listing) {
this.listing.markModified("metadata");
}
}
if (this._internalState === 1 /* CREATED */) {
await this.listing.save();
}
}
async setPrivate(bool = true) {
if (this.listing.private === bool) return;
this.listing.private = bool;
if (this._internalState === 1 /* CREATED */) {
await this.listing.save();
}
this._events.emit("visibility-change", bool);
}
/**
* Locking the room will remove it from the pool of available rooms for new clients to connect to.
*/
async lock() {
this._lockedExplicitly = arguments[0] === void 0;
if (this.#_locked) {
return;
}
this.#_locked = true;
await this.listing.updateOne({
$set: { locked: this.#_locked }
});
this._events.emit("lock");
}
/**
* Unlocking the room returns it to the pool of available rooms for new clients to connect to.
*/
async unlock() {
if (arguments[0] === void 0) {
this._lockedExplicitly = false;
}
if (!this.#_locked) {
return;
}
this.#_locked = false;
await this.listing.updateOne({
$set: { locked: this.#_locked }
});
this._events.emit("unlock");
}
send(client, messageOrType, messageOrOptions, options) {
logger.warn("DEPRECATION WARNING: use client.send(...) instead of this.send(client, ...)");
client.send(messageOrType, messageOrOptions, options);
}
broadcast(type, message, options) {
if (options && options.afterNextPatch) {
delete options.afterNextPatch;
this._afterNextPatchQueue.push(["broadcast", arguments]);
return;
}
this.broadcastMessageType(type, message, options);
}
/**
* Broadcast bytes (UInt8Arrays) to a particular room
*/
broadcastBytes(type, message, options) {
if (options && options.afterNextPatch) {
delete options.afterNextPatch;
this._afterNextPatchQueue.push(["broadcastBytes", arguments]);
return;
}
this.broadcastMessageType(type, message, options);
}
/**
* Checks whether mutations have occurred in the state, and broadcast them to all connected clients.
*/
broadcastPatch() {
if (this.onBeforePatch) {
this.onBeforePatch(this.state);
}
if (!this._simulationInterval) {
this.clock.tick();
}
if (!this.state) {
return false;
}
const hasChanges = this._serializer.applyPatches(this.clients, this.state);
this._dequeueAfterPatchMessages();
return hasChanges;
}
onMessage(messageType, callback, validate) {
this.onMessageHandlers[messageType] = this.onUncaughtException !== void 0 ? { validate, callback: wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, "onMessage", false, messageType) } : { validate, callback };
return () => delete this.onMessageHandlers[messageType];
}
/**
* Disconnect all connected clients, and then dispose the room.
*
* @param closeCode WebSocket close code (default = 4000, which is a "consented leave")
* @returns Promise<void>
*/
disconnect(closeCode = Protocol.WS_CLOSE_CONSENTED) {
if (this._internalState === 2 /* DISPOSING */) {
return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);
} else if (this._internalState === 0 /* CREATING */) {
throw new Error("cannot disconnect during onCreate()");
}
this._internalState = 2 /* DISPOSING */;
this.listing.remove();
this.#_autoDispose = true;
const delayedDisconnection = new Promise((resolve) => this._events.once("disconnect", () => resolve()));
for (const [_, reconnection] of Object.values(this._reconnections)) {
reconnection.reject(new Error("disconnecting"));
}
let numClients = this.clients.length;
if (numClients > 0) {
while (numClients--) {
this._forciblyCloseClient(this.clients[numClients], closeCode);
}
} else {
this._events.emit("dispose");
}
return delayedDisconnection;
}
async ["_onJoin"](client, authContext) {
const sessionId = client.sessionId;
client.reconnectionToken = generateId();
if (this.reservedSeatTimeouts[sessionId]) {
clearTimeout(this.reservedSeatTimeouts[sessionId]);
delete this.reservedSeatTimeouts[sessionId];
}
if (this._autoDisposeTimeout) {
clearTimeout(this._autoDisposeTimeout);
this._autoDisposeTimeout = void 0;
}
const [joinOptions, authData, isConsumed, isWaitingReconnection] = this.reservedSeats[sessionId];
if (isConsumed) {
throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, "already consumed");
}
this.reservedSeats[sessionId][2] = true;
debugMatchMaking("consuming seat reservation, sessionId: '%s' (roomId: %s)", client.sessionId, this.roomId);
client._afterNextPatchQueue = this._afterNextPatchQueue;
client.ref["onleave"] = (_) => client.state = ClientState.LEAVING;
client.ref.once("close", client.ref["onleave"]);
if (isWaitingReconnection) {
const previousReconnectionToken = this._reconnectingSessionId.get(sessionId);
if (previousReconnectionToken) {
this.clients.push(client);
await this._reconnections[previousReconnectionToken]?.[1].resolve(client);
} else {
const errorMessage = process.env.NODE_ENV === "production" ? "already consumed" : "bad reconnection token";
throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, errorMessage);
}
} else {
try {
if (authData) {
client.auth = authData;
} else if (this.onAuth !== _Room.prototype.onAuth) {
try {
client.auth = await this.onAuth(client, joinOptions, authContext);
if (!client.auth) {
throw new ServerError(ErrorCode.AUTH_FAILED, "onAuth failed");
}
} catch (e) {
delete this.reservedSeats[sessionId];
await this._decrementClientCount();
throw e;
}
}
if (client.state === ClientState.LEAVING) {
throw new ServerError(Protocol.WS_CLOSE_GOING_AWAY, "already disconnected");
}
this.clients.push(client);
Object.defineProperty(this.reservedSeats, sessionId, {
value: this.reservedSeats[sessionId],
enumerable: false
});
if (this.onJoin) {
await this.onJoin(client, joinOptions, client.auth);
}
if (client.state === ClientState.LEAVING) {
throw new Error("early_leave");
} else {
delete this.reservedSeats[sessionId];
this._events.emit("join", client);
}
} catch (e) {
await this._onLeave(client, Protocol.WS_CLOSE_GOING_AWAY);
delete this.reservedSeats[sessionId];
if (!e.code) {
e.code = ErrorCode.APPLICATION_ERROR;
}
throw e;
}
}
if (client.state === ClientState.JOINING) {
client.ref.removeListener("close", client.ref["onleave"]);
client.ref["onleave"] = this._onLeave.bind(this, client);
client.ref.once("close", client.ref["onleave"]);
client.ref.on("message", this._onMessage.bind(this, client));
client.raw(getMessageBytes[Protocol.JOIN_ROOM](
client.reconnectionToken,
this._serializer.id,
this._serializer.handshake && this._serializer.handshake()
));
}
}
/**
* Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.
* If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.
*
* @param previousClient - The client which is to be waiting until re-connection happens.
* @param seconds - Timeout period on re-connection in seconds.
*
* @returns Deferred<Client> - The differed is a promise like type.
* This type can forcibly reject the promise by calling `.reject()`.
*/
allowReconnection(previousClient, seconds) {
if (previousClient._enqueuedMessages !== void 0) {
return Promise.reject(new Error("not joined"));
}
if (seconds === void 0) {
console.warn('DEPRECATED: allowReconnection() requires a second argument. Using "manual" mode.');
seconds = "manual";
}
if (seconds === "manual") {
seconds = Infinity;
}
if (this._internalState === 2 /* DISPOSING */) {
return Promise.reject(new Error("disposing"));
}
const sessionId = previousClient.sessionId;
const reconnectionToken = previousClient.reconnectionToken;
this._reserveSeat(sessionId, true, previousClient.auth, seconds, true);
const reconnection = new Deferred();
this._reconnections[reconnectionToken] = [sessionId, reconnection];
if (seconds !== Infinity) {
this.reservedSeatTimeouts[sessionId] = setTimeout(() => reconnection.reject(false), seconds * 1e3);
}
const cleanup = () => {
delete this._reconnections[reconnectionToken];
delete this.reservedSeats[sessionId];
delete this.reservedSeatTimeouts[sessionId];
this._reconnectingSessionId.delete(sessionId);
};
reconnection.then((newClient) => {
newClient.auth = previousClient.auth;
newClient.userData = previousClient.userData;
newClient.view = previousClient.view;
previousClient.state = ClientState.RECONNECTED;
previousClient.ref = newClient.ref;
previousClient.reconnectionToken = newClient.reconnectionToken;
clearTimeout(this.reservedSeatTimeouts[sessionId]);
cleanup();
}).catch(() => {
cleanup();
this.resetAutoDisposeTimeout();
});
return reconnection;
}
resetAutoDisposeTimeout(timeoutInSeconds = 1) {
clearTimeout(this._autoDisposeTimeout);
if (!this.#_autoDispose) {
return;
}
this._autoDisposeTimeout = setTimeout(() => {
this._autoDisposeTimeout = void 0;
this._disposeIfEmpty();
}, timeoutInSeconds * 1e3);
}
broadcastMessageType(type, message, options = {}) {
debugMessage("broadcast: %O (roomId: %s)", message, this.roomId);
const encodedMessage = message instanceof Uint8Array ? getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, void 0, message) : getMessageBytes.raw(Protocol.ROOM_DATA, type, message);
const except = typeof options.except !== "undefined" ? Array.isArray(options.except) ? options.except : [options.except] : void 0;
let numClients = this.clients.length;
while (numClients--) {
const client = this.clients[numClients];
if (!except || !except.includes(client)) {
client.enqueueRaw(encodedMessage);
}
}
}
sendFullState(client) {
client.raw(this._serializer.getFullState(client));
}
_dequeueAfterPatchMessages() {
const length = this._afterNextPatchQueue.length;
if (length > 0) {
for (let i = 0; i < length; i++) {
const [target, args] = this._afterNextPatchQueue[i];
if (target === "broadcast") {
this.broadcast.apply(this, args);
} else {
target.raw.apply(target, args);
}
}
this._afterNextPatchQueue.splice(0, length);
}
}
async _reserveSeat(sessionId, joinOptions = true, authData = void 0, seconds = this.seatReservationTime, allowReconnection = false, devModeReconnection) {
if (!allowReconnection && this.hasReachedMaxClients()) {
return false;
}
this.reservedSeats[sessionId] = [joinOptions, authData, false, allowReconnection];
if (!allowReconnection) {
await this._incrementClientCount();
this.reservedSeatTimeouts[sessionId] = setTimeout(async () => {
delete this.reservedSeats[sessionId];
delete this.reservedSeatTimeouts[sessionId];
await this._decrementClientCount();
}, seconds * 1e3);
this.resetAutoDisposeTimeout(seconds);
}
if (devModeReconnection) {
this._reconnectingSessionId.set(sessionId, sessionId);
}
return true;
}
_disposeIfEmpty() {
const willDispose = this.#_onLeaveConcurrent === 0 && // no "onLeave" calls in progress
this.#_autoDispose && this._autoDisposeTimeout === void 0 && this.clients.length === 0 && Object.keys(this.reservedSeats).length === 0;
if (willDispose) {
this._events.emit("dispose");
}
return willDispose;
}
async _dispose() {
this._internalState = 2 /* DISPOSING */;
this.listing.remove();
let userReturnData;
if (this.onDispose) {
userReturnData = this.onDispose();
}
if (this.#_patchInterval) {
clearInterval(this.#_patchInterval);
this.#_patchInterval = void 0;
}
if (this._simulationInterval) {
clearInterval(this._simulationInterval);
this._simulationInterval = void 0;
}
if (this._autoDisposeTimeout) {
clearInterval(this._autoDisposeTimeout);
this._autoDisposeTimeout = void 0;
}
this.clock.clear();
this.clock.stop();
return await (userReturnData || Promise.resolve());
}
_onMessage(client, buffer) {
if (client.state === ClientState.LEAVING) {
return;
}
const it = { offset: 1 };
const code = buffer[0];
if (!buffer) {
debugAndPrintError(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`);
return;
}
if (code === Protocol.ROOM_DATA) {
const messageType = decode.stringCheck(buffer, it) ? decode.string(buffer, it) : decode.number(buffer, it);
const messageTypeHandler = this.onMessageHandlers[messageType];
let message;
try {
message = buffer.byteLength > it.offset ? unpack(buffer.subarray(it.offset, buffer.byteLength)) : void 0;
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
if (messageTypeHandler?.validate !== void 0) {
message = messageTypeHandler.validate(message);
}
} catch (e) {
debugAndPrintError(e);
client.leave(Protocol.WS_CLOSE_WITH_ERROR);
return;
}
if (messageTypeHandler) {
messageTypeHandler.callback(client, message);
} else {
(this.onMessageHandlers["*"] || this.onMessageHandlers["__no_message_handler"]).callback(client, messageType, message);
}
} else if (code === Protocol.ROOM_DATA_BYTES) {
const messageType = decode.stringCheck(buffer, it) ? decode.string(buffer, it) : decode.number(buffer, it);
const messageTypeHandler = this.onMessageHandlers[messageType];
let message = buffer.subarray(it.offset, buffer.byteLength);
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
if (messageTypeHandler?.validate !== void 0) {
message = messageTypeHandler.validate(message);
}
if (messageTypeHandler) {
messageTypeHandler.callback(client, message);
} else {
(this.onMessageHandlers["*"] || this.onMessageHandlers["__no_message_handler"]).callback(client, messageType, message);
}
} else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
client.state = ClientState.JOINED;
client._joinedAt = this.clock.elapsedTime;
if (this.state) {
this.sendFullState(client);
}
if (client._enqueuedMessages.length > 0) {
client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
}
delete client._enqueuedMessages;
} else if (code === Protocol.LEAVE_ROOM) {
this._forciblyCloseClient(client, Protocol.WS_CLOSE_CONSENTED);
}
}
_forciblyCloseClient(client, closeCode) {
client.ref.removeAllListeners("message");
client.ref.removeListener("close", client.ref["onleave"]);
this._onLeave(client, closeCode).then(() => client.leave(closeCode));
}
async _onLeave(client, code) {
debugMatchMaking("onLeave, sessionId: '%s' (close code: %d, roomId: %s)", client.sessionId, code, this.roomId);
client.state = ClientState.LEAVING;
if (!this.clients.delete(client)) {
return;
}
if (this.onLeave) {
try {
this.#_onLeaveConcurrent++;
await this.onLeave(client, code === Protocol.WS_CLOSE_CONSENTED);
} catch (e) {
debugAndPrintError(`onLeave error: ${e && e.message || e || "promise rejected"} (roomId: ${this.roomId})`);
} finally {
this.#_onLeaveConcurrent--;
}
}
if (this._reconnections[client.reconnectionToken]) {
this._reconnections[client.reconnectionToken][1].catch(async () => {
await this._onAfterLeave(client);
});
} else if (client.state !== ClientState.RECONNECTED) {
await this._onAfterLeave(client);
}
}
async _onAfterLeave(client) {
const willDispose = await this._decrementClientCount();
if (this.reservedSeats[client.sessionId] === void 0) {
this._events.emit("leave", client, willDispose);
}
}
async _incrementClientCount() {
if (!this.#_locked && this.hasReachedMaxClients()) {
this.#_maxClientsReached = true;
this.lock.call(this, true);
}
await this.listing.updateOne({
$inc: { clients: 1 },
$set: { locked: this.#_locked }
});
}
async _decrementClientCount() {
const willDispose = this._disposeIfEmpty();
if (this._internalState === 2 /* DISPOSING */) {
return true;
}
if (!willDispose) {
if (this.#_maxClientsReached && !this._lockedExplicitly) {
this.#_maxClientsReached = false;
this.unlock.call(this, true);
}
await this.listing.updateOne({
$inc: { clients: -1 },
$set: { locked: this.#_locked }
});
}
return willDispose;
}
#registerUncaughtExceptionHandlers() {
const onUncaughtException = this.onUncaughtException.bind(this);
const originalSetTimeout = this.clock.setTimeout;
this.clock.setTimeout = (cb, timeout, ...args) => {
return originalSetTimeout.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, "setTimeout"), timeout, ...args);
};
const originalSetInterval = this.clock.setInterval;
this.clock.setInterval = (cb, timeout, ...args) => {
return originalSetInterval.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, "setInterval"), timeout, ...args);
};
if (this.onCreate !== void 0) {
this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, "onCreate", true);
}
if (this.onAuth !== void 0) {
this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, "onAuth", true);
}
if (this.onJoin !== void 0) {
this.onJoin = wrapTryCatch(this.onJoin.bind(this), onUncaughtException, OnJoinException, "onJoin", true);
}
if (this.onLeave !== void 0) {
this.onLeave = wrapTryCatch(this.onLeave.bind(this), onUncaughtException, OnLeaveException, "onLeave", true);
}
if (this.onDispose !== void 0) {
this.onDispose = wrapTryCatch(this.onDispose.bind(this), onUncaughtException, OnDisposeException, "onDispose");
}
}
};
export {
DEFAULT_SEAT_RESERVATION_TIME,
Room,
RoomInternalState
};