@colyseus/core
Version:
Multiplayer Framework for Node.js.
1,161 lines (1,160 loc) • 43.7 kB
JavaScript
// packages/core/src/Room.ts
import { unpack } from "@colyseus/msgpackr";
import { decode, $changes } from "@colyseus/schema";
import { ClockTimer as 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 { getMessageBytes } from "./Protocol.mjs";
import { Deferred, generateId, wrapTryCatch } from "./utils/Utils.mjs";
import { createNanoEvents } from "./utils/nanoevents.mjs";
import { isDevMode } from "./utils/DevMode.mjs";
import { debugAndPrintError, debugMatchMaking, debugMessage } from "./Debug.mjs";
import { ServerError } from "./errors/ServerError.mjs";
import { ClientState, ClientArray } from "./Transport.mjs";
import { OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnMessageException, OnReconnectException, SimulationIntervalException, TimedEventException } from "./errors/RoomExceptions.mjs";
import { standardValidate } from "./utils/StandardSchema.mjs";
import * as matchMaker from "./MatchMaker.mjs";
import {
CloseCode,
ErrorCode,
Protocol
} from "@colyseus/shared-types";
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);
function validate(format, handler) {
return { format, handler };
}
var RoomInternalState = {
CREATING: 0,
CREATED: 1,
DISPOSING: 2
};
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;
/**
* Maximum number of messages a client can send to the server per second.
* If a client sends more messages than this, it will be disconnected.
*
* @default Infinity
*/
this.maxMessagesPerSecond = Infinity;
/**
* The array of connected clients.
*
* @see [Client instance](https://docs.colyseus.io/room#client)
*/
this.clients = new ClientArray();
/**
* 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
*/
this.seatReservationTimeout = DEFAULT_SEAT_RESERVATION_TIME;
this._events = new EventEmitter();
this._reservedSeats = {};
this._reservedSeatTimeouts = {};
this._reconnections = {};
this._reconnectionAttempts = {};
this.onMessageEvents = createNanoEvents();
this.onMessageValidators = {};
this.onMessageFallbacks = {
"__no_message_handler": (client, messageType, _) => {
const errorMessage = `room onMessage for "${messageType}" not registered.`;
debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
if (isDevMode) {
client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
} else {
client.leave(CloseCode.WITH_ERROR, errorMessage);
}
}
};
this._serializer = noneSerializer;
this._afterNextPatchQueue = [];
this._internalState = RoomInternalState.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 the room's matchmaking metadata.
*/
get metadata() {
return this._listing.metadata;
}
/**
* Set the room's matchmaking metadata.
*
* **Note**: This setter does NOT automatically persist. Use `setMatchmaking()` for automatic persistence.
*
* @example
* ```typescript
* class MyRoom extends Room<{ metadata: { difficulty: string; rating: number } }> {
* async onCreate() {
* this.metadata = { difficulty: "hard", rating: 1500 };
* }
* }
* ```
*/
set metadata(meta) {
if (this._internalState !== RoomInternalState.CREATING) {
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead.");
}
this._listing.metadata = meta;
}
#_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.setMatchmaking({ maxClients: value });
}
},
autoDispose: {
enumerable: true,
get: () => this.#_autoDispose,
set: (value) => {
if (value !== this.#_autoDispose && this._internalState !== RoomInternalState.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);
} else if (!this._simulationInterval) {
this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL);
}
}
}
});
this.patchRate = this.#_patchRate;
if (this.#_state) {
this.state = this.#_state;
}
if (this.messages !== void 0) {
if (this.messages["_"]) {
this.onMessage("*", this.messages["_"].bind(this));
delete this.messages["_"];
}
Object.entries(this.messages).forEach(([messageType, callback]) => {
if (typeof callback === "function") {
this.onMessage(messageType, callback.bind(this));
} else {
this.onMessage(messageType, callback.format, callback.handler.bind(this));
}
});
}
this.resetAutoDisposeTimeout(this.seatReservationTimeout);
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 !== RoomInternalState.CREATING && !isDevMode) {
throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation.");
}
this.#_roomId = roomId;
}
/**
* This method is called before onJoin() - this is where you should authenticate the client
* @param client - The client that is authenticating.
* @param options - The options passed to the client when it is authenticating.
* @param context - The authentication context, including the token and the client's IP address.
* @returns The authentication result.
*
* @example
* ```typescript
* return {
* userId: 123,
* username: "John Doe",
* email: "john.doe@example.com",
* };
* ```
*/
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 ? CloseCode.MAY_TRY_RECONNECT : CloseCode.SERVER_SHUTDOWN
).catch(() => {
});
}
/**
* 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 === RoomInternalState.DISPOSING;
}
/**
* @deprecated Use `seatReservationTimeout=` instead.
*/
setSeatReservationTime(seconds) {
console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`);
this.seatReservationTimeout = seconds;
return this;
}
hasReservedSeat(sessionId, reconnectionToken) {
const reservedSeat = this._reservedSeats[sessionId];
if (reservedSeat) {
return (
// not consumed
reservedSeat[2] === false || // reconnection is allowed and the reconnection token is valid.
reservedSeat[3] && this._reconnections[reconnectionToken]?.[0] === sessionId
);
} else if (typeof reconnectionToken === "string") {
return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken;
}
return false;
}
checkReconnectionToken(reconnectionToken) {
const sessionId = this._reconnections[reconnectionToken]?.[0];
const reservedSeat = this._reservedSeats[sessionId];
if (reservedSeat && reservedSeat[3]) {
return sessionId;
}
const client = this.clients.find((client2) => client2.reconnectionToken === reconnectionToken);
if (client) {
this.#_forciblyCloseClient(client, CloseCode.WITH_ERROR);
return client.sessionId;
}
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, persist = true) {
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 (persist && this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.persist(this._listing);
this._events.emit("metadata-change");
}
}
async setPrivate(bool = true, persist = true) {
if (this._listing.private === bool) return;
this._listing.private = bool;
if (persist && this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.persist(this._listing);
}
this._events.emit("visibility-change", bool);
}
/**
* Update multiple matchmaking/listing properties at once with a single persist operation.
* This is the recommended way to update room listing properties.
*
* @param updates - Object containing the properties to update
*
* @example
* ```typescript
* // Update multiple properties at once
* await this.setMatchmaking({
* metadata: { difficulty: "hard", rating: 1500 },
* private: true,
* locked: true,
* maxClients: 10
* });
* ```
*
* @example
* ```typescript
* // Update only metadata
* await this.setMatchmaking({
* metadata: { status: "in_progress" }
* });
* ```
*
* @example
* ```typescript
* // Partial metadata update (merges with existing)
* await this.setMatchmaking({
* metadata: { ...this.metadata, round: this.metadata.round + 1 }
* });
* ```
*/
async setMatchmaking(updates) {
for (const key in updates) {
if (!updates.hasOwnProperty(key)) {
continue;
}
switch (key) {
case "metadata": {
this.setMetadata(updates.metadata, false);
break;
}
case "private": {
this.setPrivate(updates.private, false);
break;
}
case "locked": {
if (updates[key]) {
this.lock.call(this, true);
this._lockedExplicitly = true;
} else {
this.unlock.call(this, true);
this._lockedExplicitly = false;
}
break;
}
case "maxClients": {
this.#_maxClients = updates.maxClients;
this._listing.maxClients = updates.maxClients;
const hasReachedMaxClients = this.hasReachedMaxClients();
if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {
this.#_maxClientsReached = false;
this.#_locked = false;
this._listing.locked = false;
updates.locked = false;
}
if (hasReachedMaxClients) {
this.#_maxClientsReached = true;
this.#_locked = true;
this._listing.locked = true;
updates.locked = true;
}
break;
}
case "clients": {
console.warn("setMatchmaking() does not allow updating 'clients' property.");
break;
}
default: {
this._listing[key] = updates[key];
break;
}
}
}
if (this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.update(this._listing, { $set: updates });
this._events.emit("metadata-change");
}
}
/**
* Lock the room. This prevents new clients from joining this room.
*/
async lock() {
this._lockedExplicitly = arguments[0] === void 0;
if (this.#_locked) {
return;
}
this.#_locked = true;
if (this._lockedExplicitly) {
await matchMaker.driver.update(this._listing, {
$set: { locked: this.#_locked }
});
}
this._events.emit("lock");
}
/**
* Unlock the room. This allows new clients to join this room, if maxClients is not reached.
*/
async unlock() {
if (arguments[0] === void 0) {
this._lockedExplicitly = false;
}
if (!this.#_locked) {
return;
}
this.#_locked = false;
if (arguments[0] === void 0) {
await matchMaker.driver.update(this._listing, {
$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 a message to all connected clients.
* @param type - The type of the message.
* @param message - The message to broadcast.
* @param options - The options for the broadcast.
*
* @example
* ```typescript
* this.broadcast('message', { message: 'Hello, world!' });
* ```
*/
broadcast(type, ...args) {
const [message, options] = args;
if (options && options.afterNextPatch) {
delete options.afterNextPatch;
this._afterNextPatchQueue.push(["broadcast", [type, ...args]]);
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, _validationSchema, _callback) {
const messageType = _messageType.toString();
const validationSchema = typeof _callback === "function" ? _validationSchema : void 0;
const callback = validationSchema === void 0 ? _validationSchema : _callback;
const removeListener = this.onMessageEvents.on(messageType, this.onUncaughtException !== void 0 ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, "onMessage", false, _messageType) : callback);
if (validationSchema !== void 0) {
this.onMessageValidators[messageType] = validationSchema;
}
return () => {
removeListener();
if (this.onMessageEvents.events[messageType].length === 0) {
delete this.onMessageValidators[messageType];
}
};
}
onMessageBytes(_messageType, _validationSchema, _callback) {
const messageType = `_$b${_messageType}`;
const validationSchema = typeof _callback === "function" ? _validationSchema : void 0;
const callback = validationSchema === void 0 ? _validationSchema : _callback;
if (validationSchema !== void 0) {
return this.onMessage(messageType, validationSchema, callback);
} else {
return this.onMessage(messageType, callback);
}
}
/**
* 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 = CloseCode.CONSENTED) {
if (this._internalState === RoomInternalState.DISPOSING) {
return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);
} else if (this._internalState === RoomInternalState.CREATING) {
throw new Error("cannot disconnect during onCreate()");
}
this._internalState = RoomInternalState.DISPOSING;
matchMaker.driver.remove(this._listing.roomId);
this.#_autoDispose = true;
const delayedDisconnection = new Promise((resolve) => this._events.once("disconnect", () => resolve()));
this._rejectPendingReconnections("disconnecting");
let numClients = this.clients.length;
if (numClients > 0) {
while (numClients--) {
this.#_forciblyCloseClient(this.clients[numClients], closeCode);
}
} else {
this._events.emit("dispose");
}
return delayedDisconnection;
}
_rejectPendingReconnections(message) {
for (const [_, reconnection] of Object.values(this._reconnections)) {
reconnection.reject(new ServerError(CloseCode.NORMAL_CLOSURE, message));
reconnection.catch(() => {
});
}
}
async _onJoin(client, authContext, connectionOptions) {
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;
}
if (this._reservedSeats[sessionId] === void 0 && connectionOptions?.reconnectionToken && this.clients.getById(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken) {
debugMatchMaking("attempting to reconnect client with a stale previous connection - sessionId: '%s', roomId: '%s'", client.sessionId, this.roomId);
this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();
const reconnectionAttemptTimeout = setTimeout(() => {
this._reconnectionAttempts[connectionOptions.reconnectionToken]?.reject(new ServerError(CloseCode.MAY_TRY_RECONNECT, "Reconnection attempt timed out"));
}, this.seatReservationTimeout * 1e3);
const cleanup = () => {
clearTimeout(reconnectionAttemptTimeout);
delete this._reconnectionAttempts[connectionOptions.reconnectionToken];
};
await this._reconnectionAttempts[connectionOptions.reconnectionToken].then(() => cleanup()).catch(() => cleanup());
if (!this._reservedSeats[sessionId]) {
throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, "failed to reconnect");
}
}
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 reconnectionToken = connectionOptions?.reconnectionToken;
if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
this.clients.push(client);
await this._reconnections[reconnectionToken]?.[1].resolve(client);
try {
if (this.onReconnect) {
await this.onReconnect(client);
}
if (client.readyState !== WebSocket.OPEN) {
throw new Error("reconnection denied");
}
if (client.state === ClientState.RECONNECTING) {
client.state = ClientState.JOINING;
}
} catch (e) {
await this._onLeave(client, CloseCode.FAILED_TO_RECONNECT);
throw e;
}
} 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(CloseCode.WITH_ERROR, "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 ServerError(ErrorCode.MATCHMAKE_UNHANDLED, "early_leave");
} else {
delete this._reservedSeats[sessionId];
this._events.emit("join", client);
}
} catch (e) {
await this._onLeave(client, CloseCode.WITH_ERROR);
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,
/**
* if skipHandshake is true, we don't need to send the handshake
* (in case client already has handshake data)
*/
connectionOptions?.skipHandshake ? void 0 : 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 client - The client that is allowed to reconnect into the room.
* @param seconds - The time in seconds that the client is allowed to reconnect into the room.
*
* @returns Deferred<Client> - The differed is a promise like type.
* This type can forcibly reject the promise by calling `.reject()`.
*
* @example
* ```typescript
* onDrop(client: Client, code: CloseCode) {
* // Allow the client to reconnect into the room with a 15 seconds timeout.
* this.allowReconnection(client, 15);
* }
* ```
*/
allowReconnection(previousClient, seconds) {
if (previousClient._enqueuedMessages !== void 0) {
return Promise.reject(new ServerError("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 === RoomInternalState.DISPOSING) {
return Promise.reject(new Error("disposing"));
}
const sessionId = previousClient.sessionId;
const reconnectionToken = previousClient.reconnectionToken;
if (this._reconnections[reconnectionToken]) {
debugMatchMaking("skipping duplicate .allowReconnection() call for client - sessionId: '%s', roomId: '%s'", sessionId, this.roomId);
return this._reconnections[reconnectionToken][1];
}
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];
};
reconnection.then((newClient) => {
newClient.auth = previousClient.auth;
newClient.userData = previousClient.userData;
newClient.view = previousClient.view;
newClient.state = ClientState.RECONNECTING;
previousClient.state = ClientState.RECONNECTED;
previousClient.ref = newClient.ref;
previousClient.reconnectionToken = newClient.reconnectionToken;
clearTimeout(this._reservedSeatTimeouts[sessionId]);
}, () => {
this.resetAutoDisposeTimeout();
}).finally(() => {
cleanup();
});
if (this._reconnectionAttempts[reconnectionToken]) {
debugMatchMaking("resolving reconnection attempt for client - sessionId: '%s', roomId: '%s'", sessionId, this.roomId);
this._reconnectionAttempts[reconnectionToken].resolve(true);
}
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.seatReservationTimeout, allowReconnection = false, devModeReconnectionToken) {
if (!allowReconnection && this.hasReachedMaxClients()) {
return false;
}
debugMatchMaking(
"reserving seat on '%s' - sessionId: '%s', roomId: '%s', processId: '%s'",
this.roomName,
sessionId,
this.roomId,
matchMaker.processId
);
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 (devModeReconnectionToken) {
const reconnection = new Deferred();
this._reconnections[devModeReconnectionToken] = [sessionId, reconnection];
clearTimeout(this._reservedSeatTimeouts[sessionId]);
this._reservedSeatTimeouts[sessionId] = setTimeout(async () => {
if (!this._reconnections[devModeReconnectionToken]) {
return;
}
delete this._reconnections[devModeReconnectionToken];
delete this._reservedSeats[sessionId];
delete this._reservedSeatTimeouts[sessionId];
if (!allowReconnection) {
await this.#_decrementClientCount();
}
this.onLeave?.({ sessionId }, CloseCode.MAY_TRY_RECONNECT);
}, seconds * 1e3);
}
return true;
}
async _reserveMultipleSeats(multipleSessionIds, multipleJoinOptions = true, multipleAuthData = void 0, seconds = this.seatReservationTimeout) {
let promises = [];
for (let i = 0; i < multipleSessionIds.length; i++) {
promises.push(this._reserveSeat(multipleSessionIds[i], multipleJoinOptions[i], multipleAuthData[i], seconds));
}
return await Promise.all(promises);
}
#_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 = RoomInternalState.DISPOSING;
if (this._listing?.roomId !== void 0) {
await matchMaker.driver.remove(this._listing.roomId);
}
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;
}
if (!buffer) {
debugAndPrintError(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`);
return;
}
if (this.clock.currentTime - client._lastMessageTime >= 1e3) {
client._numMessagesLastSecond = 0;
client._lastMessageTime = this.clock.currentTime;
} else if (++client._numMessagesLastSecond > this.maxMessagesPerSecond) {
debugMatchMaking("dropping client - sessionId: '%s' (roomId: %s), too many messages per second", client.sessionId, this.roomId);
return this.#_forciblyCloseClient(client, CloseCode.WITH_ERROR);
}
const it = { offset: 1 };
const code = buffer[0];
if (code === Protocol.ROOM_DATA) {
const messageType = decode.stringCheck(buffer, it) ? decode.string(buffer, it) : decode.number(buffer, it);
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 (this.onMessageValidators[messageType] !== void 0) {
message = standardValidate(this.onMessageValidators[messageType], message);
}
} catch (e) {
debugAndPrintError(e);
client.leave(CloseCode.WITH_ERROR);
return;
}
if (this.onMessageEvents.events[messageType]) {
this.onMessageEvents.emit(messageType, client, message);
} else if (this.onMessageEvents.events["*"]) {
this.onMessageEvents.emit("*", client, messageType, message);
} else {
this.onMessageFallbacks["__no_message_handler"](client, messageType, message);
}
} else if (code === Protocol.ROOM_DATA_BYTES) {
const messageType = decode.stringCheck(buffer, it) ? decode.string(buffer, it) : decode.number(buffer, it);
let message = buffer.subarray(it.offset, buffer.byteLength);
debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
const bytesMessageType = `_$b${messageType}`;
try {
if (this.onMessageValidators[bytesMessageType] !== void 0) {
message = standardValidate(this.onMessageValidators[bytesMessageType], message);
}
} catch (e) {
debugAndPrintError(e);
client.leave(CloseCode.WITH_ERROR);
return;
}
if (this.onMessageEvents.events[bytesMessageType]) {
this.onMessageEvents.emit(bytesMessageType, client, message);
} else if (this.onMessageEvents.events["*"]) {
this.onMessageEvents.emit("*", client, messageType, message);
} else {
this.onMessageFallbacks["__no_message_handler"](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.PING) {
client.raw(getMessageBytes[Protocol.PING]());
} else if (code === Protocol.LEAVE_ROOM) {
this.#_forciblyCloseClient(client, CloseCode.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) {
const method = code === CloseCode.CONSENTED || client.state === ClientState.RECONNECTING ? this.onLeave : this.onDrop || this.onLeave;
client.state = ClientState.LEAVING;
if (!this.clients.delete(client)) {
return;
}
if (method) {
debugMatchMaking(`${method.name}, sessionId: '%s' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
try {
this.#_onLeaveConcurrent++;
await method.call(this, client, code);
} catch (e) {
const serverError = !(e instanceof ServerError) ? new ServerError(CloseCode.WITH_ERROR, `${method.name} error`, { cause: e }) : e;
debugAndPrintError(serverError);
} finally {
this.#_onLeaveConcurrent--;
}
}
if (this._reconnections[client.reconnectionToken]) {
this._reconnections[client.reconnectionToken][1].catch(async () => {
await this.#_onAfterLeave(client, code, method === this.onDrop);
});
} else if (client.state !== ClientState.RECONNECTED) {
await this.#_onAfterLeave(client, code, method === this.onDrop);
}
}
async #_onAfterLeave(client, code, isDrop = false) {
if (isDrop && this.onLeave) {
await this.onLeave(client, code);
}
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 matchMaker.driver.update(this._listing, {
$inc: { clients: 1 },
$set: { locked: this.#_locked }
});
}
async #_decrementClientCount() {
const willDispose = this.#_disposeIfEmpty();
if (this._internalState === RoomInternalState.DISPOSING) {
return true;
}
if (!willDispose) {
if (this.#_maxClientsReached && !this._lockedExplicitly) {
this.#_maxClientsReached = false;
this.unlock.call(this, true);
}
await matchMaker.driver.update(this._listing, {
$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.onDrop !== void 0) {
this.onDrop = wrapTryCatch(this.onDrop.bind(this), onUncaughtException, OnDropException, "onDrop", true);
}
if (this.onReconnect !== void 0) {
this.onReconnect = wrapTryCatch(this.onReconnect.bind(this), onUncaughtException, OnReconnectException, "onReconnect", true);
}
if (this.onDispose !== void 0) {
this.onDispose = wrapTryCatch(this.onDispose.bind(this), onUncaughtException, OnDisposeException, "onDispose");
}
}
};
function room(options) {
class _ extends Room {
constructor() {
super();
this.messages = options.messages;
if (options.state && typeof options.state === "function") {
this.state = options.state();
}
}
}
for (const key in options) {
if (typeof options[key] === "function") {
_.prototype[key] = options[key];
}
}
return _;
}
export {
DEFAULT_SEAT_RESERVATION_TIME,
Room,
RoomInternalState,
room,
validate
};