import { unpack } from '@colyseus/msgpackr'; import { decode, type Iterator, $changes } from '@colyseus/schema'; import { ClockTimer as Clock } from '@colyseus/timer'; import { EventEmitter } from 'events'; import { logger } from './Logger.ts'; import type { Presence } from './presence/Presence.ts'; import type { Serializer } from './serializer/Serializer.ts'; import type { IRoomCache } from './matchmaker/driver.ts'; import { NoneSerializer } from './serializer/NoneSerializer.ts'; import { SchemaSerializer } from './serializer/SchemaSerializer.ts'; import { getMessageBytes } from './Protocol.ts'; import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts'; import { createNanoEvents } from './utils/nanoevents.ts'; import { isDevMode } from './utils/DevMode.ts'; import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts'; import { ServerError } from './errors/ServerError.ts'; import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts'; import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnMessageException, OnReconnectException, type RoomException, SimulationIntervalException, TimedEventException } from './errors/RoomExceptions.ts'; import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts'; import * as matchMaker from './MatchMaker.ts'; import { CloseCode, ErrorCode, Protocol, type MessageHandlerWithFormat as SharedMessageHandlerWithFormat, type MessageHandler as SharedMessageHandler, type Messages as SharedMessages, } from '@colyseus/shared-types'; const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms) const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms) const noneSerializer = new NoneSerializer(); export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15); export type SimulationCallback = (deltaTime: number) => void; export interface RoomOptions { state?: object; metadata?: any; client?: Client; } // Helper types to extract individual properties from RoomOptions export type ExtractRoomState = T extends { state?: infer S extends object } ? S : any; export type ExtractRoomMetadata = T extends { metadata?: infer M } ? M : any; export type ExtractRoomClient = T extends { client?: infer C extends Client } ? C : Client; export interface IBroadcastOptions extends ISendOptions { except?: Client | Client[]; } /** * Message handler with automatic type inference from format schema. * When a format is provided, the message type is automatically inferred from the schema. */ export type MessageHandlerWithFormat = SharedMessageHandlerWithFormat; export type MessageHandler = SharedMessageHandler; /** * A map of message types to message handlers. */ export type Messages = SharedMessages; /** * Helper function to create a validated message handler with automatic type inference. * * @example * ```typescript * messages = { * move: validate(z.object({ x: z.number(), y: z.number() }), (client, message) => { * // message.x and message.y are automatically typed as numbers * console.log(message.x, message.y); * }) * } * ``` */ export function validate( format: T, handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput) => void ): MessageHandlerWithFormat { return { format, handler }; } export const RoomInternalState = { CREATING: 0, CREATED: 1, DISPOSING: 2, } as const; export type RoomInternalState = (typeof RoomInternalState)[keyof typeof RoomInternalState]; export type OnCreateOptions> = Parameters['onCreate']>>[0]; /** * A Room class is meant to implement a game session, and/or serve as the communication channel * between a group of clients. * * - Rooms are created on demand during matchmaking by default * - Room classes must be exposed using `.define()` * * @example * ```typescript * class MyRoom extends Room<{ * state: MyState, * metadata: { difficulty: string }, * client: MyClient * }> { * // ... * } * ``` */ export class Room { '~client': ExtractRoomClient; '~state': ExtractRoomState; '~metadata': ExtractRoomMetadata; /** * 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 */ public get locked() { return this.#_locked; } /** * Get the room's matchmaking metadata. */ public get metadata(): ExtractRoomMetadata { 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 }; * } * } * ``` */ public set metadata(meta: ExtractRoomMetadata) { if (this._internalState !== RoomInternalState.CREATING) { // prevent user from setting metadata after room has been created. throw new ServerError(ErrorCode.APPLICATION_ERROR, "'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead."); } this._listing.metadata = meta; } /** * The room listing cache for matchmaking. * @internal */ private _listing: IRoomCache>; /** * Timing events tied to the room instance. * Intervals and timeouts are cleared when the room is disposed. */ public clock: Clock = new Clock(); #_roomId: string; #_roomName: string; #_onLeaveConcurrent: number = 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. */ public maxClients: number = Infinity; #_maxClientsReached: boolean = false; #_maxClients: number; /** * Automatically dispose the room when last client disconnects. * * @default true */ public autoDispose: boolean = true; #_autoDispose: boolean; /** * Frequency to send the room state to connected clients, in milliseconds. * * @default 50ms (20fps) */ public patchRate: number | null = DEFAULT_PATCH_RATE; #_patchRate: number; #_patchInterval: NodeJS.Timeout; /** * 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 */ public maxMessagesPerSecond: number = Infinity; /** * The state instance you provided to `setState()`. */ public state: ExtractRoomState; #_state: ExtractRoomState; /** * The presence instance. Check Presence API for more details. * * @see [Presence API](https://docs.colyseus.io/server/presence) */ public presence: Presence; /** * The array of connected clients. * * @see [Client instance](https://docs.colyseus.io/room#client) */ public clients: ClientArray> = 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 */ public seatReservationTimeout: number = DEFAULT_SEAT_RESERVATION_TIME; private _events = new EventEmitter(); private _reservedSeats: { [sessionId: string]: [any, any, boolean?, boolean?] } = {}; private _reservedSeatTimeouts: { [sessionId: string]: NodeJS.Timeout } = {}; private _reconnections: { [reconnectionToken: string]: [string, Deferred] } = {}; private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {}; public messages?: Messages; private onMessageEvents = createNanoEvents(); private onMessageValidators: {[message: string]: StandardSchemaV1} = {}; private onMessageFallbacks = { '__no_message_handler': (client: ExtractRoomClient, messageType: string | number, _: unknown) => { const errorMessage = `room onMessage for "${messageType}" not registered.`; debugMessage(`${errorMessage} (roomId: ${this.roomId})`); if (isDevMode) { // send error code to client in development mode client.error(ErrorCode.INVALID_PAYLOAD, errorMessage); } else { // immediately close the connection in production client.leave(CloseCode.WITH_ERROR, errorMessage); } } }; private _serializer: Serializer> = noneSerializer; private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient, ArrayLike]> = []; private _simulationInterval: NodeJS.Timeout; private _internalState: RoomInternalState = RoomInternalState.CREATING; private _lockedExplicitly: boolean = false; #_locked: boolean = false; // this timeout prevents rooms that are created by one process, but no client // ever had success joining into it on the specified interval. private _autoDisposeTimeout: NodeJS.Timeout; constructor() { 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 `onUncaughtException` is defined, it will automatically catch exceptions */ if (this.onUncaughtException !== undefined) { this.#registerUncaughtExceptionHandlers(); } } /** * This method is called by the MatchMaker before onCreate() * @internal */ private __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: ExtractRoomState) => { if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) { 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 === undefined) { 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: number) => { this.setMatchmaking({ maxClients: value }); }, }, autoDispose: { enumerable: true, get: () => this.#_autoDispose, set: (value: boolean) => { if ( value !== this.#_autoDispose && this._internalState !== RoomInternalState.DISPOSING ) { this.#_autoDispose = value; this.resetAutoDisposeTimeout(); } }, }, patchRate: { enumerable: true, get: () => this.#_patchRate, set: (milliseconds: number) => { this.#_patchRate = milliseconds; // clear previous interval in case called setPatchRate more than once if (this.#_patchInterval) { clearInterval(this.#_patchInterval); this.#_patchInterval = undefined; } if (milliseconds !== null && milliseconds !== 0) { this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds); } else if (!this._simulationInterval) { // When patchRate and no simulation interval are both set to 0, tick the clock to keep timers working this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL); } }, }, }); // set patch interval, now with the setter this.patchRate = this.#_patchRate; // set state, now with the setter if (this.#_state) { this.state = this.#_state; } // Bind messages to the room if (this.messages !== undefined) { // Handle "_" as a fallback handler if (this.messages['_']) { this.onMessage('*', (this.messages['_'] as Function).bind(this)); delete this.messages['_']; } Object.entries(this.messages).forEach(([messageType, callback]) => { if (typeof callback === 'function') { // Direct handler function - bind to room instance this.onMessage(messageType, callback.bind(this) as any); } else { // Object with format and handler - bind handler to room instance this.onMessage(messageType, callback.format, callback.handler.bind(this)); } }); } // set default _autoDisposeTimeout this.resetAutoDisposeTimeout(this.seatReservationTimeout); this.clock.start(); } /** * The name of the room you provided as first argument for `gameServer.define()`. * * @returns roomName string */ public get roomName() { return this.#_roomName; } /** * Setting the name of the room. Overwriting this property is restricted. * * @param roomName */ public set roomName(roomName: string) { if (this.#_roomName) { // prevent user from setting roomName after it has been defined. 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 */ public get roomId() { return this.#_roomId; } /** * Setting the roomId, is restricted in room lifetime except upon room creation. * * @param roomId * @returns roomId string */ public set roomId(roomId: string) { if (this._internalState !== RoomInternalState.CREATING && !isDevMode) { // prevent user from setting roomId after room has been created. throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation."); } this.#_roomId = roomId; } // Optional abstract methods /** * This method is called before the latest version of the room's state is broadcasted to all clients. */ public onBeforePatch?(state: ExtractRoomState): void | Promise; /** * This method is called when the room is created. * @param options - The options passed to the room when it is created. */ public onCreate?(options: any): void | Promise; /** * This method is called when a client joins the room. * @param client - The client that joined the room. * @param options - The options passed to the client when it joined the room. * @param auth - The data returned by the `onAuth` method - (Deprecated: use `client.auth` instead) */ public onJoin?(client: ExtractRoomClient, options?: any, auth?: any): void | Promise; /** * This method is called when a client leaves the room without consent. * You may allow the client to reconnect by calling `allowReconnection` within this method. * * @param client - The client that was dropped from the room. * @param code - The close code of the leave event. */ public onDrop?(client: ExtractRoomClient, code?: number): void | Promise; /** * This method is called when a client reconnects to the room. * @param client - The client that reconnected to the room. */ public onReconnect?(client: ExtractRoomClient): void | Promise; /** * This method is called when a client effectively leaves the room. * @param client - The client that left the room. * @param code - The close code of the leave event. */ public onLeave?(client: ExtractRoomClient, code?: number): void | Promise; /** * This method is called when the room is disposed. */ public onDispose?(): void | Promise; /** * Define a custom exception handler. * If defined, all lifecycle hooks will be wrapped by try/catch, and the exception will be forwarded to this method. * * These methods will be wrapped by try/catch: * - `onMessage` * - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose` * - `clock.setTimeout` / `clock.setInterval` * - `setSimulationInterval` * * (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it) */ public onUncaughtException?(error: RoomException, methodName: RoomMethodName): void; /** * 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", * }; * ``` */ public onAuth( client: Client, options: any, context: AuthContext ): any | Promise { return true; } static async onAuth( token: string, options: any, context: AuthContext ): Promise { 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. */ public onBeforeShutdown() { this.disconnect( (isDevMode) ? CloseCode.MAY_TRY_RECONNECT : CloseCode.SERVER_SHUTDOWN ).catch(() => {}); } /** * devMode: When `devMode` is enabled, `onCacheRoom` method is called during * graceful shutdown. * * Implement this method to return custom data to be cached. `onRestoreRoom` * will be called with the data returned by `onCacheRoom` */ public onCacheRoom?(): any; /** * devMode: When `devMode` is enabled, `onRestoreRoom` method is called during * process startup, with the data returned by the `onCacheRoom` method. */ public onRestoreRoom?(cached?: any): void; /** * Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients. * * @returns boolean */ public hasReachedMaxClients(): boolean { return ( (this.clients.length + Object.keys(this._reservedSeats).length) >= this.#_maxClients || this._internalState === RoomInternalState.DISPOSING ); } /** * @deprecated Use `seatReservationTimeout=` instead. */ public setSeatReservationTime(seconds: number) { console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`); this.seatReservationTimeout = seconds; return this; } public hasReservedSeat(sessionId: string, reconnectionToken?: string): boolean { const reservedSeat = this._reservedSeats[sessionId]; if (reservedSeat) { // seat reservation is present 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") { // potentially a stale client reference, so a reconnection attempt is possible. return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken; } return false; } public checkReconnectionToken(reconnectionToken: string) { const sessionId = this._reconnections[reconnectionToken]?.[0]; const reservedSeat = this._reservedSeats[sessionId]; if (reservedSeat && reservedSeat[3]) { return sessionId; } const client = this.clients.find((client) => client.reconnectionToken === reconnectionToken); if (client) { this.#_forciblyCloseClient(client as ExtractRoomClient & ClientPrivate, CloseCode.WITH_ERROR); return client.sessionId; } return undefined; } /** * (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. */ public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void { // clear previous interval in case called setSimulationInterval more than once if (this._simulationInterval) { clearInterval(this._simulationInterval); } if (onTickCallback) { if (this.onUncaughtException !== undefined) { onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval'); } this._simulationInterval = setInterval(() => { this.clock.tick(); onTickCallback(this.clock.deltaTime); }, delay); } } /** * @deprecated Use `.patchRate=` instead. */ public setPatchRate(milliseconds: number | null): void { this.patchRate = milliseconds; } /** * @deprecated Use `.state =` instead. */ public setState(newState: ExtractRoomState) { this.state = newState; } public setSerializer(serializer: Serializer>) { this._serializer = serializer; } public async setMetadata(meta: Partial>, persist: boolean = true) { if (!this._listing.metadata) { this._listing.metadata = meta as ExtractRoomMetadata; } else { for (const field in meta) { if (!meta.hasOwnProperty(field)) { continue; } this._listing.metadata[field] = meta[field]; } // `MongooseDriver` workaround: persit metadata mutations if ('markModified' in this._listing) { (this._listing as any).markModified('metadata'); } } if (persist && this._internalState === RoomInternalState.CREATED) { await matchMaker.driver.persist(this._listing); // emit metadata-change event to update lobby listing this._events.emit('metadata-change'); } } public async setPrivate(bool: boolean = true, persist: boolean = true) { if (this._listing.private === bool) return; this._listing.private = bool; if (persist && this._internalState === RoomInternalState.CREATED) { await matchMaker.driver.persist(this._listing); } // emit visibility-change event to update lobby 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 } * }); * ``` */ public async setMatchmaking(updates: { metadata?: ExtractRoomMetadata; private?: boolean; locked?: boolean; maxClients?: number; unlisted?: boolean; [key: string]: any; }) { 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]) { // @ts-ignore this.lock.call(this, true); this._lockedExplicitly = true; } else { // @ts-ignore this.unlock.call(this, true); this._lockedExplicitly = false; } break; } case 'maxClients': { this.#_maxClients = updates.maxClients; this._listing.maxClients = updates.maxClients; const hasReachedMaxClients = this.hasReachedMaxClients(); // unlock room if maxClients has been increased if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) { this.#_maxClientsReached = false; this.#_locked = false; this._listing.locked = false; updates.locked = false; } // lock room if maxClients has been decreased 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: { // Allow any other listing properties to be updated this._listing[key] = updates[key]; break; } } } // Only persist if room is not CREATING if (this._internalState === RoomInternalState.CREATED) { await matchMaker.driver.update(this._listing, { $set: updates }); // emit metadata-change event to update lobby listing this._events.emit('metadata-change'); } } /** * Lock the room. This prevents new clients from joining this room. */ public async lock() { // rooms locked internally aren't explicit locks. this._lockedExplicitly = (arguments[0] === undefined); // skip if already locked. if (this.#_locked) { return; } this.#_locked = true; // Only persist if this is an explicit lock/unlock 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. */ public async unlock() { // only internal usage passes arguments to this function. if (arguments[0] === undefined) { this._lockedExplicitly = false; } // skip if already locked if (!this.#_locked) { return; } this.#_locked = false; // Only persist if this is an explicit lock/unlock if (arguments[0] === undefined) { await matchMaker.driver.update(this._listing, { $set: { locked: this.#_locked }, }); } this._events.emit('unlock'); } /** * @deprecated Use `client.send(...)` instead. */ public send(client: Client, type: string | number, message: any, options?: ISendOptions): void; public send(client: Client, messageOrType: any, messageOrOptions?: any | ISendOptions, options?: ISendOptions): void { 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!' }); * ``` */ public broadcast['~messages'] & string | number>( type: K, ...args: MessageArgs['~messages'][K], IBroadcastOptions> ) { 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 */ public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) { if (options && options.afterNextPatch) { delete options.afterNextPatch; this._afterNextPatchQueue.push(['broadcastBytes', arguments]); return; } this.broadcastMessageType(type as string, message, options); } /** * Checks whether mutations have occurred in the state, and broadcast them to all connected clients. */ public 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); // broadcast messages enqueued for "after patch" this._dequeueAfterPatchMessages(); return hasChanges; } /** * Register a message handler for a specific message type. * This method is used to handle messages sent by clients to the room. * @param messageType - The type of the message. * @param callback - The callback to call when the message is received. * @returns A function to unbind the callback. * * @example * ```typescript * this.onMessage('message', (client, message) => { * console.log(message); * }); * ``` * * @example * ```typescript * const unbind = this.onMessage('message', (client, message) => { * console.log(message); * }); * * // Unbind the callback when no longer needed * unbind(); * ``` */ public onMessage>( messageType: '*', callback: (client: C, type: string | number, message: T) => void ); public onMessage>( messageType: string | number, callback: (client: C, message: T) => void, ); public onMessage>( messageType: string | number, validationSchema: StandardSchemaV1, callback: (client: C, message: T) => void, ); public onMessage( _messageType: '*' | string | number, _validationSchema: StandardSchemaV1 | ((...args: any[]) => void), _callback?: (...args: any[]) => void, ) { const messageType = _messageType.toString(); const validationSchema = (typeof _callback === 'function') ? _validationSchema as StandardSchemaV1 : undefined; const callback = (validationSchema === undefined) ? _validationSchema as (...args: any[]) => void : _callback; const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined) ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType) : callback); if (validationSchema !== undefined) { this.onMessageValidators[messageType] = validationSchema; } // returns a method to unbind the callback return () => { removeListener(); if (this.onMessageEvents.events[messageType].length === 0) { delete this.onMessageValidators[messageType]; } }; } public onMessageBytes>( // public onMessageBytes( messageType: string | number, callback: (client: C, message: T) => void, ); public onMessageBytes>( // public onMessageBytes( messageType: string | number, validationSchema: StandardSchemaV1, callback: (client: C, message: T) => void, ); public onMessageBytes( _messageType: string | number, _validationSchema: StandardSchemaV1 | ((...args: any[]) => void), _callback?: (...args: any[]) => void, ) { const messageType = `_$b${_messageType}`; const validationSchema = (typeof _callback === 'function') ? _validationSchema as StandardSchemaV1 : undefined; const callback = (validationSchema === undefined) ? _validationSchema as (...args: any[]) => void : _callback; if (validationSchema !== undefined) { return this.onMessage(messageType, validationSchema as any, callback as any); } else { return this.onMessage(messageType, callback as any); } } /** * Disconnect all connected clients, and then dispose the room. * * @param closeCode WebSocket close code (default = 4000, which is a "consented leave") * @returns Promise */ public disconnect(closeCode: number = CloseCode.CONSENTED): Promise { // skip if already disposing 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())); // reject pending reconnections this._rejectPendingReconnections("disconnecting"); let numClients = this.clients.length; if (numClients > 0) { // clients may have `async onLeave`, room will be disposed after they're fulfilled while (numClients--) { this.#_forciblyCloseClient(this.clients[numClients] as ExtractRoomClient & ClientPrivate, closeCode); } } else { // no clients connected, dispose immediately. this._events.emit('dispose'); } return delayedDisconnection; } private _rejectPendingReconnections(message: string) { for (const [_, reconnection] of Object.values(this._reconnections)) { reconnection.reject(new ServerError(CloseCode.NORMAL_CLOSURE, message)); // Suppress unhandled rejection — expected during shutdown/devMode // restart, handled downstream by _onLeave's .catch() handler. reconnection.catch(() => {}); } } private async _onJoin( client: ExtractRoomClient & ClientPrivate, authContext: AuthContext, connectionOptions?: { reconnectionToken?: string, skipHandshake?: boolean } ) { const sessionId = client.sessionId; // generate unique private reconnection token // (each new reconnection receives a new reconnection token) client.reconnectionToken = generateId(); if (this._reservedSeatTimeouts[sessionId]) { clearTimeout(this._reservedSeatTimeouts[sessionId]); delete this._reservedSeatTimeouts[sessionId]; } // clear auto-dispose timeout. if (this._autoDisposeTimeout) { clearTimeout(this._autoDisposeTimeout); this._autoDisposeTimeout = undefined; } // // user may be trying to reconnect while the old connection is still open (stale) // (e.g. during network switches, where the old connection is still open while a new reconnection attempt is being made) // if ( this._reservedSeats[sessionId] === undefined && 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 * 1000); 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"); } } // get seat reservation options and clear it const [joinOptions, authData, isConsumed, isWaitingReconnection] = this._reservedSeats[sessionId]; // // TODO: remove this check on 1.0.0 // - the seat reservation is used to keep track of number of clients and their pending seats (see `hasReachedMaxClients`) // - when we fully migrate to static onAuth(), the seat reservation can be removed immediately here // - if async onAuth() is in use, the seat reservation is removed after onAuth() is fulfilled. // - mark reservation as "consumed" // if (isConsumed) { throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, "already consumed"); } this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed" debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId); // share "after next patch queue" reference with every client. client._afterNextPatchQueue = this._afterNextPatchQueue; // add temporary callback to keep track of disconnections during `onJoin`. 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 for reconnection: // (end user may customize the reconnection token at this step) // await this._reconnections[reconnectionToken]?.[1].resolve(client); try { if (this.onReconnect) { await this.onReconnect(client); } // FIXME: we shouldn't rely on WebSocket specific API here (make it transport agnostic) if (client.readyState !== WebSocket.OPEN) { throw new Error("reconnection denied"); } // client.leave() may have been called during onReconnect() if (client.state === ClientState.RECONNECTING) { // switch client state from RECONNECTING to JOINING // (to allow to attach messages to the client again) 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" // trick possible fraudsters... : "bad reconnection token" // ...or developers 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) { // remove seat reservation delete this._reservedSeats[sessionId]; await this.#_decrementClientCount(); throw e; } } // // On async onAuth, client may have been disconnected. // if (client.state === ClientState.LEAVING) { throw new ServerError(CloseCode.WITH_ERROR, 'already disconnected'); } this.clients.push(client); // // Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it // (https://github.com/colyseus/colyseus/issues/726) // Object.defineProperty(this._reservedSeats, sessionId, { value: this._reservedSeats[sessionId], enumerable: false, }); if (this.onJoin) { // TODO: deprecate auth as 3rd argument on Colyseus 1.0 await this.onJoin(client, joinOptions, client.auth); } // @ts-ignore: client left during `onJoin`, call _onLeave immediately. if (client.state === ClientState.LEAVING) { throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, "early_leave"); } else { // remove seat reservation delete this._reservedSeats[sessionId]; // emit 'join' to room handler this._events.emit('join', client); } } catch (e: any) { await this._onLeave(client, CloseCode.WITH_ERROR); // remove seat reservation delete this._reservedSeats[sessionId]; // make sure an error code is provided. if (!e.code) { e.code = ErrorCode.APPLICATION_ERROR; } throw e; } } // state might already be ClientState.LEAVING here if (client.state === ClientState.JOINING) { client.ref.removeListener('close', client.ref['onleave']); // only bind _onLeave after onJoin has been successful client.ref['onleave'] = this._onLeave.bind(this, client); client.ref.once('close', client.ref['onleave']); // allow client to send messages after onJoin has succeeded. client.ref.on('message', this._onMessage.bind(this, client)); // confirm room id that matches the room name requested to join 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) ? undefined : 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 - 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); * } * ``` */ public allowReconnection(previousClient: Client, seconds: number | "manual"): Deferred { // // Return rejected promise if client has never fully JOINED. // // (having `_enqueuedMessages !== undefined` means that the client has never been at "ClientState.JOINED" state) // if ((previousClient as unknown as ClientPrivate)._enqueuedMessages !== undefined) { // @ts-ignore return Promise.reject(new ServerError("not joined")); } if (seconds === undefined) { // TODO: remove this check console.warn("DEPRECATED: allowReconnection() requires a second argument. Using \"manual\" mode."); seconds = "manual"; } if (seconds === "manual") { seconds = Infinity; } if (this._internalState === RoomInternalState.DISPOSING) { // @ts-ignore return Promise.reject(new Error("disposing")); } const sessionId = previousClient.sessionId; const reconnectionToken = previousClient.reconnectionToken; // // prevent duplicate .allowReconnection() calls // (may occur during network switches, where the old connection is still // open while a new reconnection attempt is being made) // 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); // keep reconnection reference in case the user reconnects into this room. const reconnection = new Deferred(); this._reconnections[reconnectionToken] = [sessionId, reconnection]; if (seconds !== Infinity) { // expire seat reservation after timeout this._reservedSeatTimeouts[sessionId] = setTimeout(() => reconnection.reject(false), seconds * 1000); } 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; // for convenience: populate previous client reference with new client previousClient.state = ClientState.RECONNECTED; previousClient.ref = newClient.ref; previousClient.reconnectionToken = newClient.reconnectionToken; clearTimeout(this._reservedSeatTimeouts[sessionId]); }, () => { this.resetAutoDisposeTimeout(); }).finally(() => { cleanup(); }); // // If a reconnection attempt is already in progress, resolve it // // This step ensures reconnection works when network changes (e.g., // switching Wi-Fi), as the original connection may still be open while a // new reconnection attempt is being made. // if (this._reconnectionAttempts[reconnectionToken]) { debugMatchMaking('resolving reconnection attempt for client - sessionId: \'%s\', roomId: \'%s\'', sessionId, this.roomId); this._reconnectionAttempts[reconnectionToken].resolve(true); } return reconnection; } private resetAutoDisposeTimeout(timeoutInSeconds: number = 1) { clearTimeout(this._autoDisposeTimeout); if (!this.#_autoDispose) { return; } this._autoDisposeTimeout = setTimeout(() => { this._autoDisposeTimeout = undefined; this.#_disposeIfEmpty(); }, timeoutInSeconds * 1000); } private broadcastMessageType(type: number | string, message?: any | Uint8Array, options: IBroadcastOptions = {}) { debugMessage("broadcast: %O (roomId: %s)", message, this.roomId); const encodedMessage = (message instanceof Uint8Array) ? getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, undefined, message) : getMessageBytes.raw(Protocol.ROOM_DATA, type, message) const except = (typeof (options.except) !== "undefined") ? Array.isArray(options.except) ? options.except : [options.except] : undefined; let numClients = this.clients.length; while (numClients--) { const client = this.clients[numClients]; if (!except || !except.includes(client)) { client.enqueueRaw(encodedMessage); } } } private sendFullState(client: Client): void { client.raw(this._serializer.getFullState(client)); } private _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 as any); } else { (target as Client).raw.apply(target, args as any); } } // new messages may have been added in the meantime, // let's splice the ones that have been processed this._afterNextPatchQueue.splice(0, length); } } private async _reserveSeat( sessionId: string, joinOptions: any = true, authData: any = undefined, seconds: number = this.seatReservationTimeout, allowReconnection: boolean = false, devModeReconnectionToken?: string, ) { 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 * 1000); this.resetAutoDisposeTimeout(seconds); } if (devModeReconnectionToken) { const reconnection = new Deferred(); this._reconnections[devModeReconnectionToken] = [sessionId, reconnection]; // If the client doesn't reconnect within the timeout, call onLeave // so the room can clean up stale state (e.g. delete player data). 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 } as any, CloseCode.MAY_TRY_RECONNECT); }, seconds * 1000); } return true; } private async _reserveMultipleSeats( multipleSessionIds: string[], multipleJoinOptions: any = true, multipleAuthData: any = undefined, seconds: number = this.seatReservationTimeout, ) { let promises: Promise[] = []; 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 === undefined && this.clients.length === 0 && Object.keys(this._reservedSeats).length === 0 ); if (willDispose) { this._events.emit('dispose'); } return willDispose; } async #_dispose(): Promise { this._internalState = RoomInternalState.DISPOSING; // If the room is still CREATING, the roomId is not yet set. if (this._listing?.roomId !== undefined) { await matchMaker.driver.remove(this._listing.roomId); } let userReturnData; if (this.onDispose) { userReturnData = this.onDispose(); } if (this.#_patchInterval) { clearInterval(this.#_patchInterval); this.#_patchInterval = undefined; } if (this._simulationInterval) { clearInterval(this._simulationInterval); this._simulationInterval = undefined; } if (this._autoDisposeTimeout) { clearInterval(this._autoDisposeTimeout); this._autoDisposeTimeout = undefined; } // clear all timeouts/intervals + force to stop ticking this.clock.clear(); this.clock.stop(); return await (userReturnData || Promise.resolve()); } private _onMessage(client: ExtractRoomClient & ClientPrivate, buffer: Buffer) { // skip if client is on LEAVING state. if (client.state === ClientState.LEAVING) { return; } if (!buffer) { debugAndPrintError(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`); return; } // reset message count every second if (this.clock.currentTime - client._lastMessageTime >= 1000) { client._numMessagesLastSecond = 0; client._lastMessageTime = this.clock.currentTime; } else if (++client._numMessagesLastSecond > this.maxMessagesPerSecond) { // drop client if it sends more messages than the maximum allowed per second 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: Iterator = { 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)) : undefined; debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId); // custom message validation if (this.onMessageValidators[messageType] !== undefined) { message = standardValidate(this.onMessageValidators[messageType], message); } } catch (e: any) { debugAndPrintError(e); client.leave(CloseCode.WITH_ERROR); return; } if (this.onMessageEvents.events[messageType]) { this.onMessageEvents.emit(messageType as string, 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: any = buffer.subarray(it.offset, buffer.byteLength); debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId); const bytesMessageType = `_$b${messageType}`; // custom message validation try { if (this.onMessageValidators[bytesMessageType] !== undefined) { message = standardValidate(this.onMessageValidators[bytesMessageType], message); } } catch (e: any) { 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) { // join room has been acknowledged by the client client.state = ClientState.JOINED; client._joinedAt = this.clock.elapsedTime; // send current state when new client joins the room if (this.state) { this.sendFullState(client); } // dequeue messages sent before client has joined effectively (on user-defined `onJoin`) 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: ExtractRoomClient & ClientPrivate, closeCode: number) { // stop receiving messages from this client client.ref.removeAllListeners('message'); // prevent "onLeave" from being called twice if player asks to leave client.ref.removeListener('close', client.ref['onleave']); // only effectively close connection when "onLeave" is fulfilled this._onLeave(client, closeCode).then(() => client.leave(closeCode)); } private async _onLeave(client: ExtractRoomClient, code?: number): Promise { // reconnecting check is required here to allow user to deny reconnection via onReconnect() const method = (code === CloseCode.CONSENTED || client.state === ClientState.RECONNECTING) ? this.onLeave : (this.onDrop || this.onLeave); client.state = ClientState.LEAVING; if (!this.clients.delete(client)) { // skip if client already left the room 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: any) { const serverError = (!(e instanceof ServerError)) ? new ServerError(CloseCode.WITH_ERROR, `${method.name} error`, { cause: e }) : e; debugAndPrintError(serverError); } finally { this.#_onLeaveConcurrent--; } } // check for manual "reconnection" flow if (this._reconnections[client.reconnectionToken]) { this._reconnections[client.reconnectionToken][1].catch(async () => { await this.#_onAfterLeave(client, code, method === this.onDrop); }); // @ts-ignore (client.state may be modified at onLeave()) } else if (client.state !== ClientState.RECONNECTED) { await this.#_onAfterLeave(client, code, method === this.onDrop); } } async #_onAfterLeave(client: ExtractRoomClient, code?: number, isDrop: boolean = false) { if (isDrop && this.onLeave) { await this.onLeave(client, code); } // try to dispose immediately if client reconnection isn't set up. const willDispose = await this.#_decrementClientCount(); // trigger 'leave' only if seat reservation has been fully consumed if (this._reservedSeats[client.sessionId] === undefined) { this._events.emit('leave', client, willDispose); } } async #_incrementClientCount() { // lock automatically when maxClients is reached if (!this.#_locked && this.hasReachedMaxClients()) { this.#_maxClientsReached = true; // @ts-ignore 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; } // unlock if room is available for new connections if (!willDispose) { if (this.#_maxClientsReached && !this._lockedExplicitly) { this.#_maxClientsReached = false; // @ts-ignore this.unlock.call(this, true); } // update room listing cache 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 !== undefined) { this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, 'onCreate', true); } if (this.onAuth !== undefined) { this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, 'onAuth', true); } if (this.onJoin !== undefined) { this.onJoin = wrapTryCatch(this.onJoin.bind(this), onUncaughtException, OnJoinException, 'onJoin', true); } if (this.onLeave !== undefined) { this.onLeave = wrapTryCatch(this.onLeave.bind(this), onUncaughtException, OnLeaveException, 'onLeave', true); } if (this.onDrop !== undefined) { this.onDrop = wrapTryCatch(this.onDrop.bind(this), onUncaughtException, OnDropException, 'onDrop', true); } if (this.onReconnect !== undefined) { this.onReconnect = wrapTryCatch(this.onReconnect.bind(this), onUncaughtException, OnReconnectException, 'onReconnect', true); } if (this.onDispose !== undefined) { this.onDispose = wrapTryCatch(this.onDispose.bind(this), onUncaughtException, OnDisposeException, 'onDispose'); } } } /** * (WIP) Alternative, method-based room definition. * We should be able to define */ type RoomLifecycleMethods = | 'messages' | 'onCreate' | 'onJoin' | 'onLeave' | 'onDispose' | 'onCacheRoom' | 'onRestoreRoom' | 'onDrop' | 'onReconnect' | 'onUncaughtException' | 'onAuth' | 'onBeforeShutdown' | 'onBeforePatch'; type DefineRoomOptions = Partial, RoomLifecycleMethods>> & { state?: ExtractRoomState | (() => ExtractRoomState); } & ThisType, RoomLifecycleMethods>> & ThisType> ; export function room(options: DefineRoomOptions) { class _ extends Room { messages = options.messages; constructor() { super(); if (options.state && typeof options.state === 'function') { this.state = options.state(); } } } // Copy all methods to the prototype for (const key in options) { if (typeof options[key] === 'function') { _.prototype[key] = options[key]; } } return _ as typeof Room; }