@colyseus/core
Version:
Multiplayer Framework for Node.js.
4 lines • 89.7 kB
Source Map (JSON)
{
"version": 3,
"sources": ["../src/Room.ts"],
"sourcesContent": ["import { unpack } from '@colyseus/msgpackr';\nimport { decode, type Iterator, $changes } from '@colyseus/schema';\nimport { ClockTimer as Clock } from '@colyseus/timer';\n\nimport { EventEmitter } from 'events';\nimport { logger } from './Logger.ts';\n\nimport type { Presence } from './presence/Presence.ts';\nimport type { Serializer } from './serializer/Serializer.ts';\nimport type { IRoomCache } from './matchmaker/driver.ts';\n\nimport { NoneSerializer } from './serializer/NoneSerializer.ts';\nimport { SchemaSerializer } from './serializer/SchemaSerializer.ts';\n\nimport { getMessageBytes } from './Protocol.ts';\nimport { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';\nimport { createNanoEvents } from './utils/nanoevents.ts';\nimport { isDevMode } from './utils/DevMode.ts';\n\nimport { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';\nimport { ServerError } from './errors/ServerError.ts';\nimport { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';\nimport { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnMessageException, OnReconnectException, type RoomException, SimulationIntervalException, TimedEventException } from './errors/RoomExceptions.ts';\n\nimport { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';\nimport * as matchMaker from './MatchMaker.ts';\n\nimport {\n CloseCode,\n ErrorCode,\n Protocol,\n type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,\n type MessageHandler as SharedMessageHandler,\n type Messages as SharedMessages,\n} from '@colyseus/shared-types';\n\nconst DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)\nconst DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)\nconst noneSerializer = new NoneSerializer();\n\nexport const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);\n\nexport type SimulationCallback = (deltaTime: number) => void;\n\nexport interface RoomOptions {\n state?: object;\n metadata?: any;\n client?: Client;\n}\n\n// Helper types to extract individual properties from RoomOptions\nexport type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;\nexport type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;\nexport type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;\n\nexport interface IBroadcastOptions extends ISendOptions {\n except?: Client | Client[];\n}\n\n/**\n * Message handler with automatic type inference from format schema.\n * When a format is provided, the message type is automatically inferred from the schema.\n */\nexport type MessageHandlerWithFormat<T extends StandardSchemaV1 = any, This = any> =\n SharedMessageHandlerWithFormat<T, Client, This>;\n\nexport type MessageHandler<This = any> = SharedMessageHandler<Client, This>;\n\n/**\n * A map of message types to message handlers.\n */\nexport type Messages<This extends Room> = SharedMessages<This, Client>;\n\n/**\n * Helper function to create a validated message handler with automatic type inference.\n *\n * @example\n * ```typescript\n * messages = {\n * move: validate(z.object({ x: z.number(), y: z.number() }), (client, message) => {\n * // message.x and message.y are automatically typed as numbers\n * console.log(message.x, message.y);\n * })\n * }\n * ```\n */\nexport function validate<T extends StandardSchemaV1, This = any>(\n format: T,\n handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void\n): MessageHandlerWithFormat<T, This> {\n return { format, handler };\n}\n\nexport const RoomInternalState = {\n CREATING: 0,\n CREATED: 1,\n DISPOSING: 2,\n} as const;\nexport type RoomInternalState = (typeof RoomInternalState)[keyof typeof RoomInternalState];\n\nexport type OnCreateOptions<T extends Type<Room>> = Parameters<NonNullable<InstanceType<T>['onCreate']>>[0];\n\n/**\n * A Room class is meant to implement a game session, and/or serve as the communication channel\n * between a group of clients.\n *\n * - Rooms are created on demand during matchmaking by default\n * - Room classes must be exposed using `.define()`\n *\n * @example\n * ```typescript\n * class MyRoom extends Room<{\n * state: MyState,\n * metadata: { difficulty: string },\n * client: MyClient\n * }> {\n * // ...\n * }\n * ```\n */\nexport class Room<T extends RoomOptions = RoomOptions> {\n '~client': ExtractRoomClient<T>;\n '~state': ExtractRoomState<T>;\n '~metadata': ExtractRoomMetadata<T>;\n\n /**\n * This property will change on these situations:\n * - The maximum number of allowed clients has been reached (`maxClients`)\n * - You manually locked, or unlocked the room using lock() or `unlock()`.\n *\n * @readonly\n */\n public get locked() {\n return this.#_locked;\n }\n\n /**\n * Get the room's matchmaking metadata.\n */\n public get metadata(): ExtractRoomMetadata<T> {\n return this._listing.metadata;\n }\n\n /**\n * Set the room's matchmaking metadata.\n *\n * **Note**: This setter does NOT automatically persist. Use `setMatchmaking()` for automatic persistence.\n *\n * @example\n * ```typescript\n * class MyRoom extends Room<{ metadata: { difficulty: string; rating: number } }> {\n * async onCreate() {\n * this.metadata = { difficulty: \"hard\", rating: 1500 };\n * }\n * }\n * ```\n */\n public set metadata(meta: ExtractRoomMetadata<T>) {\n if (this._internalState !== RoomInternalState.CREATING) {\n // prevent user from setting metadata after room has been created.\n throw new ServerError(ErrorCode.APPLICATION_ERROR, \"'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead.\");\n }\n\n this._listing.metadata = meta;\n }\n\n /**\n * The room listing cache for matchmaking.\n * @internal\n */\n private _listing: IRoomCache<ExtractRoomMetadata<T>>;\n\n /**\n * Timing events tied to the room instance.\n * Intervals and timeouts are cleared when the room is disposed.\n */\n public clock: Clock = new Clock();\n\n #_roomId: string;\n #_roomName: string;\n #_onLeaveConcurrent: number = 0; // number of onLeave calls in progress\n\n /**\n * Maximum number of clients allowed to connect into the room. When room reaches this limit,\n * it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,\n * the room will be unlocked as soon as a client disconnects from it.\n */\n public maxClients: number = Infinity;\n #_maxClientsReached: boolean = false;\n #_maxClients: number;\n\n /**\n * Automatically dispose the room when last client disconnects.\n *\n * @default true\n */\n public autoDispose: boolean = true;\n #_autoDispose: boolean;\n\n /**\n * Frequency to send the room state to connected clients, in milliseconds.\n *\n * @default 50ms (20fps)\n */\n public patchRate: number | null = DEFAULT_PATCH_RATE;\n #_patchRate: number;\n #_patchInterval: NodeJS.Timeout;\n\n /**\n * Maximum number of messages a client can send to the server per second.\n * If a client sends more messages than this, it will be disconnected.\n *\n * @default Infinity\n */\n public maxMessagesPerSecond: number = Infinity;\n\n /**\n * The state instance you provided to `setState()`.\n */\n public state: ExtractRoomState<T>;\n #_state: ExtractRoomState<T>;\n\n /**\n * The presence instance. Check Presence API for more details.\n *\n * @see [Presence API](https://docs.colyseus.io/server/presence)\n */\n public presence: Presence;\n\n /**\n * The array of connected clients.\n *\n * @see [Client instance](https://docs.colyseus.io/room#client)\n */\n public clients: ClientArray<ExtractRoomClient<T>> = new ClientArray();\n\n /**\n * Set the number of seconds a room can wait for a client to effectively join the room.\n * You should consider how long your `onAuth()` will have to wait for setting a different seat reservation time.\n * The default value is 15 seconds. You may set the `COLYSEUS_SEAT_RESERVATION_TIME`\n * environment variable if you'd like to change the seat reservation time globally.\n *\n * @default 15 seconds\n */\n public seatReservationTimeout: number = DEFAULT_SEAT_RESERVATION_TIME;\n\n private _events = new EventEmitter();\n\n private _reservedSeats: { [sessionId: string]: [any, any, boolean?, boolean?] } = {};\n private _reservedSeatTimeouts: { [sessionId: string]: NodeJS.Timeout } = {};\n\n private _reconnections: { [reconnectionToken: string]: [string, Deferred] } = {};\n private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {};\n\n public messages?: Messages<any>;\n\n private onMessageEvents = createNanoEvents();\n private onMessageValidators: {[message: string]: StandardSchemaV1} = {};\n\n private onMessageFallbacks = {\n '__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {\n const errorMessage = `room onMessage for \"${messageType}\" not registered.`;\n debugMessage(`${errorMessage} (roomId: ${this.roomId})`);\n\n if (isDevMode) {\n // send error code to client in development mode\n client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);\n\n } else {\n // immediately close the connection in production\n client.leave(CloseCode.WITH_ERROR, errorMessage);\n }\n }\n };\n\n private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;\n private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];\n\n private _simulationInterval: NodeJS.Timeout;\n\n private _internalState: RoomInternalState = RoomInternalState.CREATING;\n\n private _lockedExplicitly: boolean = false;\n #_locked: boolean = false;\n\n // this timeout prevents rooms that are created by one process, but no client\n // ever had success joining into it on the specified interval.\n private _autoDisposeTimeout: NodeJS.Timeout;\n\n constructor() {\n this._events.once('dispose', () => {\n this.#_dispose()\n .catch((e) => debugAndPrintError(`onDispose error: ${(e && e.stack || e.message || e || 'promise rejected')} (roomId: ${this.roomId})`))\n .finally(() => this._events.emit('disconnect'));\n });\n\n /**\n * If `onUncaughtException` is defined, it will automatically catch exceptions\n */\n if (this.onUncaughtException !== undefined) {\n this.#registerUncaughtExceptionHandlers();\n }\n }\n\n /**\n * This method is called by the MatchMaker before onCreate()\n * @internal\n */\n private __init() {\n this.#_state = this.state;\n this.#_autoDispose = this.autoDispose;\n this.#_patchRate = this.patchRate;\n this.#_maxClients = this.maxClients;\n\n Object.defineProperties(this, {\n state: {\n enumerable: true,\n get: () => this.#_state,\n set: (newState: ExtractRoomState<T>) => {\n if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) {\n this.setSerializer(new SchemaSerializer());\n } else if ('_definition' in newState) {\n throw new Error(\"@colyseus/schema v2 compatibility currently missing (reach out if you need it)\");\n } else if ($changes === undefined) {\n throw new Error(\"Multiple @colyseus/schema versions detected. Please make sure you don't have multiple versions of @colyseus/schema installed.\");\n }\n this._serializer.reset(newState);\n this.#_state = newState;\n },\n },\n\n maxClients: {\n enumerable: true,\n get: () => this.#_maxClients,\n set: (value: number) => {\n this.setMatchmaking({ maxClients: value });\n },\n },\n\n autoDispose: {\n enumerable: true,\n get: () => this.#_autoDispose,\n set: (value: boolean) => {\n if (\n value !== this.#_autoDispose &&\n this._internalState !== RoomInternalState.DISPOSING\n ) {\n this.#_autoDispose = value;\n this.resetAutoDisposeTimeout();\n }\n },\n },\n\n patchRate: {\n enumerable: true,\n get: () => this.#_patchRate,\n set: (milliseconds: number) => {\n this.#_patchRate = milliseconds;\n // clear previous interval in case called setPatchRate more than once\n if (this.#_patchInterval) {\n clearInterval(this.#_patchInterval);\n this.#_patchInterval = undefined;\n }\n if (milliseconds !== null && milliseconds !== 0) {\n this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds);\n } else if (!this._simulationInterval) {\n // When patchRate and no simulation interval are both set to 0, tick the clock to keep timers working\n this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL);\n }\n },\n },\n });\n\n // set patch interval, now with the setter\n this.patchRate = this.#_patchRate;\n\n // set state, now with the setter\n if (this.#_state) {\n this.state = this.#_state;\n }\n\n // Bind messages to the room\n if (this.messages !== undefined) {\n\n // Handle \"_\" as a fallback handler\n if (this.messages['_']) {\n this.onMessage('*', (this.messages['_'] as Function).bind(this));\n delete this.messages['_'];\n }\n\n Object.entries(this.messages).forEach(([messageType, callback]) => {\n if (typeof callback === 'function') {\n // Direct handler function - bind to room instance\n this.onMessage(messageType, callback.bind(this) as any);\n } else {\n // Object with format and handler - bind handler to room instance\n this.onMessage(messageType, callback.format, callback.handler.bind(this));\n }\n });\n }\n\n // set default _autoDisposeTimeout\n this.resetAutoDisposeTimeout(this.seatReservationTimeout);\n\n this.clock.start();\n }\n\n /**\n * The name of the room you provided as first argument for `gameServer.define()`.\n *\n * @returns roomName string\n */\n public get roomName() { return this.#_roomName; }\n /**\n * Setting the name of the room. Overwriting this property is restricted.\n *\n * @param roomName\n */\n public set roomName(roomName: string) {\n if (this.#_roomName) {\n // prevent user from setting roomName after it has been defined.\n throw new ServerError(ErrorCode.APPLICATION_ERROR, \"'roomName' cannot be overwritten.\");\n }\n this.#_roomName = roomName;\n }\n\n /**\n * A unique, auto-generated, 9-character-long id of the room.\n * You may replace `this.roomId` during `onCreate()`.\n *\n * @returns roomId string\n */\n public get roomId() { return this.#_roomId; }\n\n /**\n * Setting the roomId, is restricted in room lifetime except upon room creation.\n *\n * @param roomId\n * @returns roomId string\n */\n public set roomId(roomId: string) {\n if (this._internalState !== RoomInternalState.CREATING && !isDevMode) {\n // prevent user from setting roomId after room has been created.\n throw new ServerError(ErrorCode.APPLICATION_ERROR, \"'roomId' can only be overridden upon room creation.\");\n }\n this.#_roomId = roomId;\n }\n\n // Optional abstract methods\n\n /**\n * This method is called before the latest version of the room's state is broadcasted to all clients.\n */\n public onBeforePatch?(state: ExtractRoomState<T>): void | Promise<any>;\n\n /**\n * This method is called when the room is created.\n * @param options - The options passed to the room when it is created.\n */\n public onCreate?(options: any): void | Promise<any>;\n\n /**\n * This method is called when a client joins the room.\n * @param client - The client that joined the room.\n * @param options - The options passed to the client when it joined the room.\n * @param auth - The data returned by the `onAuth` method - (Deprecated: use `client.auth` instead)\n */\n public onJoin?(client: ExtractRoomClient<T>, options?: any, auth?: any): void | Promise<any>;\n\n /**\n * This method is called when a client leaves the room without consent.\n * You may allow the client to reconnect by calling `allowReconnection` within this method.\n *\n * @param client - The client that was dropped from the room.\n * @param code - The close code of the leave event.\n */\n public onDrop?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;\n\n /**\n * This method is called when a client reconnects to the room.\n * @param client - The client that reconnected to the room.\n */\n public onReconnect?(client: ExtractRoomClient<T>): void | Promise<any>;\n\n /**\n * This method is called when a client effectively leaves the room.\n * @param client - The client that left the room.\n * @param code - The close code of the leave event.\n */\n public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;\n\n /**\n * This method is called when the room is disposed.\n */\n public onDispose?(): void | Promise<any>;\n\n /**\n * Define a custom exception handler.\n * If defined, all lifecycle hooks will be wrapped by try/catch, and the exception will be forwarded to this method.\n *\n * These methods will be wrapped by try/catch:\n * - `onMessage`\n * - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`\n * - `clock.setTimeout` / `clock.setInterval`\n * - `setSimulationInterval`\n *\n * (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)\n */\n public onUncaughtException?(error: RoomException, methodName: RoomMethodName): void;\n\n /**\n * This method is called before onJoin() - this is where you should authenticate the client\n * @param client - The client that is authenticating.\n * @param options - The options passed to the client when it is authenticating.\n * @param context - The authentication context, including the token and the client's IP address.\n * @returns The authentication result.\n *\n * @example\n * ```typescript\n * return {\n * userId: 123,\n * username: \"John Doe\",\n * email: \"john.doe@example.com\",\n * };\n * ```\n */\n public onAuth(\n client: Client,\n options: any,\n context: AuthContext\n ): any | Promise<any> {\n return true;\n }\n\n static async onAuth(\n token: string,\n options: any,\n context: AuthContext\n ): Promise<unknown> {\n return true;\n }\n\n /**\n * This method is called during graceful shutdown of the server process\n * You may override this method to dispose the room in your own way.\n *\n * Once process reaches room count of 0, the room process will be terminated.\n */\n public onBeforeShutdown() {\n this.disconnect(\n (isDevMode)\n ? CloseCode.MAY_TRY_RECONNECT\n : CloseCode.SERVER_SHUTDOWN\n ).catch(() => {});\n }\n\n /**\n * devMode: When `devMode` is enabled, `onCacheRoom` method is called during\n * graceful shutdown.\n *\n * Implement this method to return custom data to be cached. `onRestoreRoom`\n * will be called with the data returned by `onCacheRoom`\n */\n public onCacheRoom?(): any;\n\n /**\n * devMode: When `devMode` is enabled, `onRestoreRoom` method is called during\n * process startup, with the data returned by the `onCacheRoom` method.\n */\n public onRestoreRoom?(cached?: any): void;\n\n /**\n * Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients.\n *\n * @returns boolean\n */\n public hasReachedMaxClients(): boolean {\n return (\n (this.clients.length + Object.keys(this._reservedSeats).length) >= this.#_maxClients ||\n this._internalState === RoomInternalState.DISPOSING\n );\n }\n\n /**\n * @deprecated Use `seatReservationTimeout=` instead.\n */\n public setSeatReservationTime(seconds: number) {\n console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`);\n this.seatReservationTimeout = seconds;\n return this;\n }\n\n public hasReservedSeat(sessionId: string, reconnectionToken?: string): boolean {\n const reservedSeat = this._reservedSeats[sessionId];\n\n if (reservedSeat) {\n // seat reservation is present\n return (\n // not consumed\n (reservedSeat[2] === false) ||\n // reconnection is allowed and the reconnection token is valid.\n (reservedSeat[3] && this._reconnections[reconnectionToken]?.[0] === sessionId)\n )\n\n } else if (typeof(reconnectionToken) === \"string\") {\n // potentially a stale client reference, so a reconnection attempt is possible.\n return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken;\n }\n\n return false;\n }\n\n public checkReconnectionToken(reconnectionToken: string) {\n const sessionId = this._reconnections[reconnectionToken]?.[0];\n const reservedSeat = this._reservedSeats[sessionId];\n\n if (reservedSeat && reservedSeat[3]) {\n return sessionId;\n }\n\n const client = this.clients.find((client) => client.reconnectionToken === reconnectionToken);\n if (client) {\n this.#_forciblyCloseClient(client as ExtractRoomClient<T> & ClientPrivate, CloseCode.WITH_ERROR);\n return client.sessionId;\n }\n\n return undefined;\n }\n\n /**\n * (Optional) Set a simulation interval that can change the state of the game.\n * The simulation interval is your game loop.\n *\n * @default 16.6ms (60fps)\n *\n * @param onTickCallback - You can implement your physics or world updates here!\n * This is a good place to update the room state.\n * @param delay - Interval delay on executing `onTickCallback` in milliseconds.\n */\n public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {\n // clear previous interval in case called setSimulationInterval more than once\n if (this._simulationInterval) { clearInterval(this._simulationInterval); }\n\n if (onTickCallback) {\n if (this.onUncaughtException !== undefined) {\n onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval');\n }\n\n this._simulationInterval = setInterval(() => {\n this.clock.tick();\n onTickCallback(this.clock.deltaTime);\n }, delay);\n }\n }\n\n /**\n * @deprecated Use `.patchRate=` instead.\n */\n public setPatchRate(milliseconds: number | null): void {\n this.patchRate = milliseconds;\n }\n\n /**\n * @deprecated Use `.state =` instead.\n */\n public setState(newState: ExtractRoomState<T>) {\n this.state = newState;\n }\n\n public setSerializer(serializer: Serializer<ExtractRoomState<T>>) {\n this._serializer = serializer;\n }\n\n public async setMetadata(meta: Partial<ExtractRoomMetadata<T>>, persist: boolean = true) {\n if (!this._listing.metadata) {\n this._listing.metadata = meta as ExtractRoomMetadata<T>;\n\n } else {\n for (const field in meta) {\n if (!meta.hasOwnProperty(field)) { continue; }\n this._listing.metadata[field] = meta[field];\n }\n\n // `MongooseDriver` workaround: persit metadata mutations\n if ('markModified' in this._listing) {\n (this._listing as any).markModified('metadata');\n }\n }\n\n if (persist && this._internalState === RoomInternalState.CREATED) {\n await matchMaker.driver.persist(this._listing);\n\n // emit metadata-change event to update lobby listing\n this._events.emit('metadata-change');\n }\n }\n\n public async setPrivate(bool: boolean = true, persist: boolean = true) {\n if (this._listing.private === bool) return;\n\n this._listing.private = bool;\n\n if (persist && this._internalState === RoomInternalState.CREATED) {\n await matchMaker.driver.persist(this._listing);\n }\n\n // emit visibility-change event to update lobby listing\n this._events.emit('visibility-change', bool);\n }\n\n /**\n * Update multiple matchmaking/listing properties at once with a single persist operation.\n * This is the recommended way to update room listing properties.\n *\n * @param updates - Object containing the properties to update\n *\n * @example\n * ```typescript\n * // Update multiple properties at once\n * await this.setMatchmaking({\n * metadata: { difficulty: \"hard\", rating: 1500 },\n * private: true,\n * locked: true,\n * maxClients: 10\n * });\n * ```\n *\n * @example\n * ```typescript\n * // Update only metadata\n * await this.setMatchmaking({\n * metadata: { status: \"in_progress\" }\n * });\n * ```\n *\n * @example\n * ```typescript\n * // Partial metadata update (merges with existing)\n * await this.setMatchmaking({\n * metadata: { ...this.metadata, round: this.metadata.round + 1 }\n * });\n * ```\n */\n public async setMatchmaking(updates: {\n metadata?: ExtractRoomMetadata<T>;\n private?: boolean;\n locked?: boolean;\n maxClients?: number;\n unlisted?: boolean;\n [key: string]: any;\n }) {\n for (const key in updates) {\n if (!updates.hasOwnProperty(key)) { continue; }\n\n switch (key) {\n case 'metadata': {\n this.setMetadata(updates.metadata, false);\n break;\n }\n\n case 'private': {\n this.setPrivate(updates.private, false);\n break;\n }\n\n case 'locked': {\n if (updates[key]) {\n // @ts-ignore\n this.lock.call(this, true);\n this._lockedExplicitly = true;\n } else {\n // @ts-ignore\n this.unlock.call(this, true);\n this._lockedExplicitly = false;\n }\n break;\n }\n\n case 'maxClients': {\n this.#_maxClients = updates.maxClients;\n this._listing.maxClients = updates.maxClients;\n\n const hasReachedMaxClients = this.hasReachedMaxClients();\n\n // unlock room if maxClients has been increased\n if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {\n this.#_maxClientsReached = false;\n this.#_locked = false;\n this._listing.locked = false;\n updates.locked = false;\n }\n\n // lock room if maxClients has been decreased\n if (hasReachedMaxClients) {\n this.#_maxClientsReached = true;\n this.#_locked = true;\n this._listing.locked = true;\n updates.locked = true;\n }\n\n break;\n }\n\n case 'clients': {\n console.warn(\"setMatchmaking() does not allow updating 'clients' property.\");\n break;\n }\n\n default: {\n // Allow any other listing properties to be updated\n this._listing[key] = updates[key];\n break;\n }\n }\n }\n\n // Only persist if room is not CREATING\n if (this._internalState === RoomInternalState.CREATED) {\n await matchMaker.driver.update(this._listing, { $set: updates });\n\n // emit metadata-change event to update lobby listing\n this._events.emit('metadata-change');\n }\n }\n\n /**\n * Lock the room. This prevents new clients from joining this room.\n */\n public async lock() {\n // rooms locked internally aren't explicit locks.\n this._lockedExplicitly = (arguments[0] === undefined);\n\n // skip if already locked.\n if (this.#_locked) { return; }\n\n this.#_locked = true;\n\n // Only persist if this is an explicit lock/unlock\n if (this._lockedExplicitly) {\n await matchMaker.driver.update(this._listing, {\n $set: { locked: this.#_locked },\n });\n }\n\n this._events.emit('lock');\n }\n\n /**\n * Unlock the room. This allows new clients to join this room, if maxClients is not reached.\n */\n public async unlock() {\n // only internal usage passes arguments to this function.\n if (arguments[0] === undefined) {\n this._lockedExplicitly = false;\n }\n\n // skip if already locked\n if (!this.#_locked) { return; }\n\n this.#_locked = false;\n\n // Only persist if this is an explicit lock/unlock\n if (arguments[0] === undefined) {\n await matchMaker.driver.update(this._listing, {\n $set: { locked: this.#_locked },\n });\n }\n\n this._events.emit('unlock');\n }\n\n /**\n * @deprecated Use `client.send(...)` instead.\n */\n public send(client: Client, type: string | number, message: any, options?: ISendOptions): void;\n public send(client: Client, messageOrType: any, messageOrOptions?: any | ISendOptions, options?: ISendOptions): void {\n logger.warn('DEPRECATION WARNING: use client.send(...) instead of this.send(client, ...)');\n client.send(messageOrType, messageOrOptions, options);\n }\n\n /**\n * Broadcast a message to all connected clients.\n * @param type - The type of the message.\n * @param message - The message to broadcast.\n * @param options - The options for the broadcast.\n *\n * @example\n * ```typescript\n * this.broadcast('message', { message: 'Hello, world!' });\n * ```\n */\n public broadcast<K extends keyof ExtractRoomClient<T>['~messages'] & string | number>(\n type: K,\n ...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>\n ) {\n const [message, options] = args;\n if (options && options.afterNextPatch) {\n delete options.afterNextPatch;\n this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);\n return;\n }\n\n this.broadcastMessageType(type, message, options);\n }\n\n /**\n * Broadcast bytes (UInt8Arrays) to a particular room\n */\n public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {\n if (options && options.afterNextPatch) {\n delete options.afterNextPatch;\n this._afterNextPatchQueue.push(['broadcastBytes', arguments]);\n return;\n }\n\n this.broadcastMessageType(type as string, message, options);\n }\n\n /**\n * Checks whether mutations have occurred in the state, and broadcast them to all connected clients.\n */\n public broadcastPatch() {\n if (this.onBeforePatch) {\n this.onBeforePatch(this.state);\n }\n\n if (!this._simulationInterval) {\n this.clock.tick();\n }\n\n if (!this.state) {\n return false;\n }\n\n const hasChanges = this._serializer.applyPatches(this.clients, this.state);\n\n // broadcast messages enqueued for \"after patch\"\n this._dequeueAfterPatchMessages();\n\n return hasChanges;\n }\n\n /**\n * Register a message handler for a specific message type.\n * This method is used to handle messages sent by clients to the room.\n * @param messageType - The type of the message.\n * @param callback - The callback to call when the message is received.\n * @returns A function to unbind the callback.\n *\n * @example\n * ```typescript\n * this.onMessage('message', (client, message) => {\n * console.log(message);\n * });\n * ```\n *\n * @example\n * ```typescript\n * const unbind = this.onMessage('message', (client, message) => {\n * console.log(message);\n * });\n *\n * // Unbind the callback when no longer needed\n * unbind();\n * ```\n */\n public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(\n messageType: '*',\n callback: (client: C, type: string | number, message: T) => void\n );\n public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(\n messageType: string | number,\n callback: (client: C, message: T) => void,\n );\n public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(\n messageType: string | number,\n validationSchema: StandardSchemaV1<T>,\n callback: (client: C, message: T) => void,\n );\n public onMessage<T = any>(\n _messageType: '*' | string | number,\n _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),\n _callback?: (...args: any[]) => void,\n ) {\n const messageType = _messageType.toString();\n\n const validationSchema = (typeof _callback === 'function')\n ? _validationSchema as StandardSchemaV1<T>\n : undefined;\n\n const callback = (validationSchema === undefined)\n ? _validationSchema as (...args: any[]) => void\n : _callback;\n\n const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)\n ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)\n : callback);\n\n if (validationSchema !== undefined) {\n this.onMessageValidators[messageType] = validationSchema;\n }\n\n // returns a method to unbind the callback\n return () => {\n removeListener();\n if (this.onMessageEvents.events[messageType].length === 0) {\n delete this.onMessageValidators[messageType];\n }\n };\n }\n\n public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(\n // public onMessageBytes<T = any, C extends Client = TClient>(\n messageType: string | number,\n callback: (client: C, message: T) => void,\n );\n public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(\n // public onMessageBytes<T = any, C extends Client = TClient>(\n messageType: string | number,\n validationSchema: StandardSchemaV1<T>,\n callback: (client: C, message: T) => void,\n );\n public onMessageBytes<T = any>(\n _messageType: string | number,\n _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),\n _callback?: (...args: any[]) => void,\n ) {\n const messageType = `_$b${_messageType}`;\n\n const validationSchema = (typeof _callback === 'function')\n ? _validationSchema as StandardSchemaV1<T>\n : undefined;\n\n const callback = (validationSchema === undefined)\n ? _validationSchema as (...args: any[]) => void\n : _callback;\n\n if (validationSchema !== undefined) {\n return this.onMessage(messageType, validationSchema as any, callback as any);\n } else {\n return this.onMessage(messageType, callback as any);\n }\n }\n\n /**\n * Disconnect all connected clients, and then dispose the room.\n *\n * @param closeCode WebSocket close code (default = 4000, which is a \"consented leave\")\n * @returns Promise<void>\n */\n public disconnect(closeCode: number = CloseCode.CONSENTED): Promise<any> {\n // skip if already disposing\n if (this._internalState === RoomInternalState.DISPOSING) {\n return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);\n\n } else if (this._internalState === RoomInternalState.CREATING) {\n throw new Error(\"cannot disconnect during onCreate()\");\n }\n\n this._internalState = RoomInternalState.DISPOSING;\n matchMaker.driver.remove(this._listing.roomId);\n\n this.#_autoDispose = true;\n\n const delayedDisconnection = new Promise<void>((resolve) =>\n this._events.once('disconnect', () => resolve()));\n\n // reject pending reconnections\n this._rejectPendingReconnections(\"disconnecting\");\n\n let numClients = this.clients.length;\n if (numClients > 0) {\n // clients may have `async onLeave`, room will be disposed after they're fulfilled\n while (numClients--) {\n this.#_forciblyCloseClient(this.clients[numClients] as ExtractRoomClient<T> & ClientPrivate, closeCode);\n }\n\n } else {\n // no clients connected, dispose immediately.\n this._events.emit('dispose');\n }\n\n return delayedDisconnection;\n }\n\n private _rejectPendingReconnections(message: string) {\n for (const [_, reconnection] of Object.values(this._reconnections)) {\n reconnection.reject(new ServerError(CloseCode.NORMAL_CLOSURE, message));\n // Suppress unhandled rejection \u2014 expected during shutdown/devMode\n // restart, handled downstream by _onLeave's .catch() handler.\n reconnection.catch(() => {});\n }\n }\n\n private async _onJoin(\n client: ExtractRoomClient<T> & ClientPrivate,\n authContext: AuthContext,\n connectionOptions?: { reconnectionToken?: string, skipHandshake?: boolean }\n ) {\n const sessionId = client.sessionId;\n\n // generate unique private reconnection token\n // (each new reconnection receives a new reconnection token)\n client.reconnectionToken = generateId();\n\n if (this._reservedSeatTimeouts[sessionId]) {\n clearTimeout(this._reservedSeatTimeouts[sessionId]);\n delete this._reservedSeatTimeouts[sessionId];\n }\n\n // clear auto-dispose timeout.\n if (this._autoDisposeTimeout) {\n clearTimeout(this._autoDisposeTimeout);\n this._autoDisposeTimeout = undefined;\n }\n\n //\n // user may be trying to reconnect while the old connection is still open (stale)\n // (e.g. during network switches, where the old connection is still open while a new reconnection attempt is being made)\n //\n if (\n this._reservedSeats[sessionId] === undefined &&\n connectionOptions?.reconnectionToken &&\n this.clients.getById(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken\n ) {\n debugMatchMaking('attempting to reconnect client with a stale previous connection - sessionId: \\'%s\\', roomId: \\'%s\\'', client.sessionId, this.roomId);\n this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();\n\n const reconnectionAttemptTimeout = setTimeout(() => {\n this._reconnectionAttempts[connectionOptions.reconnectionToken]?.reject(new ServerError(CloseCode.MAY_TRY_RECONNECT, 'Reconnection attempt timed out'));\n }, this.seatReservationTimeout * 1000);\n\n const cleanup = () => {\n clearTimeout(reconnectionAttemptTimeout);\n delete this._reconnectionAttempts[connectionOptions.reconnectionToken];\n }\n\n await this._reconnectionAttempts[connectionOptions.reconnectionToken]\n .then(() => cleanup())\n .catch(() => cleanup());\n\n if (!this._reservedSeats[sessionId]) {\n throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, \"failed to reconnect\");\n }\n }\n\n // get seat reservation options and clear it\n const [joinOptions, authData, isConsumed, isWaitingReconnection] = this._reservedSeats[sessionId];\n\n //\n // TODO: remove this check on 1.0.0\n // - the seat reservation is used to keep track of number of clients and their pending seats (see `hasReachedMaxClients`)\n // - when we fully migrate to static onAuth(), the seat reservation can be removed immediately here\n // - if async onAuth() is in use, the seat reservation is removed after onAuth() is fulfilled.\n // - mark reservation as \"consumed\"\n //\n if (isConsumed) {\n throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, \"already consumed\");\n }\n this._reservedSeats[sessionId][2] = true; // flag seat reservation as \"consumed\"\n debugMatchMaking('consuming seat reservation, sessionId: \\'%s\\' (roomId: %s)', client.sessionId, this.roomId);\n\n // share \"after next patch queue\" reference with every client.\n client._afterNextPatchQueue = this._afterNextPatchQueue;\n\n // add temporary callback to keep track of disconnections during `onJoin`.\n client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;\n client.ref.once('close', client.ref['onleave']);\n\n if (isWaitingReconnection) {\n const reconnectionToken = connectionOptions?.reconnectionToken;\n if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {\n this.clients.push(client);\n\n //\n // await for reconnection:\n // (end user may customize the reconnection token at this step)\n //\n await this._reconnections[reconnectionToken]?.[1].resolve(client);\n\n try {\n if (this.onReconnect) {\n await this.onReconnect(client);\n }\n\n // FIXME: we shouldn't rely on WebSocket specific API here (make it transport agnostic)\n if (client.readyState !== WebSocket.OPEN) {\n throw new Error(\"reconnection denied\");\n }\n\n // client.leave() may have been called during onReconnect()\n if (client.state === ClientState.RECONNECTING) {\n // switch client state from RECONNECTING to JOINING\n // (to allow to attach messages to the client again)\n client.state = ClientState.JOINING;\n }\n\n } catch (e) {\n await this._onLeave(client, CloseCode.FAILED_TO_RECONNECT);\n throw e;\n }\n\n } else {\n const errorMessage = (process.env.NODE_ENV === 'production')\n ? \"already consumed\" // trick possible fraudsters...\n : \"bad reconnection token\" // ...or developers\n throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, errorMessage);\n }\n\n } else {\n try {\n if (authData) {\n client.auth = authData;\n\n } else if (this.onAuth !== Room.prototype.onAuth) {\n try {\n client.auth = await this.onAuth(client, joinOptions, authContext);\n\n if (!client.auth) {\n throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');\n }\n\n } catch (e) {\n // remove seat reservation\n delete this._reservedSeats[sessionId];\n await this.#_decrementClientCount();\n throw e;\n }\n }\n\n //\n // On async onAuth, client may have been disconnected.\n //\n if (client.state === ClientState.LEAVING) {\n throw new ServerError(CloseCode.WITH_ERROR, 'already disconnected');\n }\n\n this.clients.push(client);\n\n //\n // Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it\n // (https://github.com/colyseus/colyseus/issues/726)\n //\n Object.defineProperty(this._reservedSeats, sessionId, {\n value: this._reservedSeats[sessionId],\n enumerable: false,\n });\n\n if (this.onJoin) {\n // TODO: deprecate auth as 3rd argument on Colyseus 1.0\n await this.onJoin(client, joinOptions, client.auth);\n }\n\n // @ts-ignore: client left during `onJoin`, call _onLeave immediately.\n if (client.state === ClientState.LEAVING) {\n throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, \"early_leave\");\n\n } else {\n // remove seat reservation\n delete this._reservedSeats[sessionId];\n\n // emit 'join' to room handler\n this._events.emit('join', client);\n }\n\n } catch (e: any) {\n await this._onLeave(client, CloseCode.WITH_ERROR);\n\n // remove seat reservation\n delete this._reservedSeats[sessionId];\n\n // make sure an error code is provided.\n if (!e.code) {\n e.code = ErrorCode.APPLICATION_ERROR;\n }\n\n throw e;\n }\n }\n\n // state might already be ClientState.LEAVING here\n if (client.state === ClientState.JOINING) {\n client.ref.removeListener('close', client.ref['onleave']);\n\n // only bind _onLeave after onJoin has been successful\n client.ref['onleave'] = this._onLeave.bind(this, client);\n client.ref.once('close', client.ref['onleave']);\n\n // allow client to send messages after onJoin has succeeded.\n client.ref.on('message', this._onMessage.bind(this, client));\n\n // confirm room id that matches the room name requested to join\n client.raw(getMessageBytes[Protocol.JOIN_ROOM](\n client.reconnectionToken,\n this._serializer.id,\n /**\n * if skipHandshake is true, we don't need to send the handshake\n * (in case client already has handshake data)\n */\n (connectionOptions?.skipHandshake)\n ? undefined\n : this._serializer.handshake && this._serializer.handshake(),\n ));\n }\n }\n\n /**\n * Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.\n * If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.\n *\n * @param client - The client that is allowed to reconnect into the room.\n * @param seconds - The time in seconds that the client is allowed to reconnect into the room.\n *\n * @returns Deferred<Client> - The differed is a promise like type.\n * This type can forcibly reject the promise by calling `.reject()`.\n *\n * @example\n * ```typescript\n * onDrop(client: Client, code: CloseCode) {\n * // Allow the client to reconnect into the room with a 15 seconds timeout.\n * this.allowReconnection(client, 15);\n * }\n * ```\n */\n public allowReconnection(previousClient: Client, seconds: number | \"manual\"): Deferred<Client> {\n //\n // Return rejected promise if client has never fully JOINED.\n //\n // (having `_enqueuedMessages !== undefined` means that the client has never been at \"ClientState.JOINED\" state)\n //\n if ((previousClient as unknown as ClientPrivate)._enqueuedMessages !== undefined) {\n // @ts-ignore\n return Promise.reject(new ServerError(\"not joined\"));\n }\n\n if (seconds === undefined) { // TODO: remove this check\n console.warn(\"DEPRECATED: allowReconnection() requires a second argument. Using \\\"manual\\\" mode.\");\n seconds = \"manual\";\n }\n\n if (seconds === \"manual\") {\n seconds = Infinity;\n }\n\n if (this._internalState === RoomInternalState.DISPOSING) {\n // @ts-ignore\n return Promise.reject(new Error(\"disposing\"));\n }\n\n const sessionId = previousClient.sessionId;\n const reconnectionToken = previousClient.reconnectionToken;\n\n //\n // prevent duplicate .allowReconnection() calls\n // (may occur during network switches, where the old connection is still\n // open while a new reconnection attempt is being made)\n //\n if (this._reconnections[reconnectionToken]) {\n debugMatchMaking('skipping duplicate .allowReconnection() call for client - sessionId: \\'%s\\', roomId: \\'%s\\'', sessionId, this.roomId);\n return this._reconnections[reconnectionToken][1];\n }\n\n this._reserveSeat(sessionId, true, previousClient.auth, seconds, true);\n\n // keep reconnection reference in case the user reconnects into this room.\n const reconnection = new Deferred<Client & ClientPrivate>();\n this._reconnections[reconnectionToken] = [sessionId, reconnection];\n\n if (seconds !== Infinity) {\n // expire seat reservation after timeout\n this._reservedSeatTimeouts[sessionId] = setTimeout(() =>\n reconnection.reject(false), seconds * 1000);\n }\n\n const cleanup = () => {\n delete this._reconnections[reconnectionToken];\n delete this._reservedSeats[sessionId];\n delete this._reservedSeatTimeouts[sessionId];\n };\n\n reconnection.then((newClient) => {\n newClient.auth = previousClient.auth;\n newClient.userData = previousClient.userData;\n newClient.view = previousClient.view;\n newClient.state = ClientState.RECONNECTING;\n\n // for convenience: populate previous client reference with new client\n previousClient.state = ClientState.RECONNECTED;\n previousClient.ref = newClient.ref;\n previousClient.reconnectionToken = newClient.reconnectionToken;\n clearTimeout(this._reservedSeatTimeouts[sessionId]);\n\n }, () => {\n this.resetAutoDisposeTimeout();\n\n }).finally(() => {\n cleanup();\n });\n\n //\n // If a reconnection attempt is already in progress, resolve it\n //\n // This step ensures reconnection works when network changes (e.g.,\n // switching Wi-Fi), as the original connection may still be open while a\n // new reconnection attempt is being made.\n //\n if (this._reconnectionAttempts[reconnectionToken]) {\n debugMatchMaking('resolving reconnection attempt for client - sessionId: \\'%s\\', roomId: \\'%s\\'', sessionId, this.roomId);\n this._reconnectionAttempts[reconnectionToken].resolve(true);\n }\n\n return reconnection;\n }\n\n private resetAutoDisposeTimeout(timeoutInSeconds: number = 1) {\n clearTimeout(this._autoDisposeTimeout);\n\n if (!this.#_autoDispose) {\n return;\n }\n\n this._autoDisposeTimeout = setTimeout(() => {\n this._autoDisposeTimeout = undefined;\n this.#_disposeIfEmpty();\n }, timeoutInSeconds * 1000);\n }\n\n private broadcastMessageType(type: number | string, message?: any | Uint8Array, options: IBroadcastOptions = {}) {\n debugMessage(\"broadcast: %O (roomId: %s)\", message, this.roomId);\n\n const encodedMessage = (message instanceof Uint8Array)\n ? getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, undefined,