UNPKG

@colyseus/core

Version:

Multiplayer Framework for Node.js.

1,161 lines (1,160 loc) 43.7 kB
// 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 };