@colyseus/core
Version:
Multiplayer Framework for Node.js.
1,489 lines (1,260 loc) • 63.7 kB
text/typescript
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> = T extends { state?: infer S extends object } ? S : any;
export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
export type ExtractRoomClient<T> = 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<T extends StandardSchemaV1 = any, This = any> =
SharedMessageHandlerWithFormat<T, Client, This>;
export type MessageHandler<This = any> = SharedMessageHandler<Client, This>;
/**
* A map of message types to message handlers.
*/
export type Messages<This extends Room> = SharedMessages<This, Client>;
/**
* 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<T extends StandardSchemaV1, This = any>(
format: T,
handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void
): MessageHandlerWithFormat<T, This> {
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<T extends Type<Room>> = Parameters<NonNullable<InstanceType<T>['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<T extends RoomOptions = RoomOptions> {
'~client': ExtractRoomClient<T>;
'~state': ExtractRoomState<T>;
'~metadata': ExtractRoomMetadata<T>;
/**
* 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<T> {
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<T>) {
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<ExtractRoomMetadata<T>>;
/**
* 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<T>;
#_state: ExtractRoomState<T>;
/**
* 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<ExtractRoomClient<T>> = 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<any>;
private onMessageEvents = createNanoEvents();
private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
private onMessageFallbacks = {
'__no_message_handler': (client: ExtractRoomClient<T>, 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<ExtractRoomState<T>> = noneSerializer;
private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];
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<T>) => {
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<T>): void | Promise<any>;
/**
* 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<any>;
/**
* 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<T>, options?: any, auth?: any): void | Promise<any>;
/**
* 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<T>, code?: number): void | Promise<any>;
/**
* This method is called when a client reconnects to the room.
* @param client - The client that reconnected to the room.
*/
public onReconnect?(client: ExtractRoomClient<T>): void | Promise<any>;
/**
* 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<T>, code?: number): void | Promise<any>;
/**
* This method is called when the room is disposed.
*/
public onDispose?(): void | Promise<any>;
/**
* 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<any> {
return true;
}
static async onAuth(
token: string,
options: any,
context: AuthContext
): Promise<unknown> {
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<T> & 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<T>) {
this.state = newState;
}
public setSerializer(serializer: Serializer<ExtractRoomState<T>>) {
this._serializer = serializer;
}
public async setMetadata(meta: Partial<ExtractRoomMetadata<T>>, persist: boolean = true) {
if (!this._listing.metadata) {
this._listing.metadata = meta as ExtractRoomMetadata<T>;
} 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<T>;
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<K extends keyof ExtractRoomClient<T>['~messages'] & string | number>(
type: K,
...args: MessageArgs<ExtractRoomClient<T>['~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<T = any, C extends Client = ExtractRoomClient<T>>(
messageType: '*',
callback: (client: C, type: string | number, message: T) => void
);
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
messageType: string | number,
callback: (client: C, message: T) => void,
);
public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
messageType: string | number,
validationSchema: StandardSchemaV1<T>,
callback: (client: C, message: T) => void,
);
public onMessage<T = any>(
_messageType: '*' | string | number,
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
_callback?: (...args: any[]) => void,
) {
const messageType = _messageType.toString();
const validationSchema = (typeof _callback === 'function')
? _validationSchema as StandardSchemaV1<T>
: 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<T = any, C extends Client = ExtractRoomClient<T>>(
// public onMessageBytes<T = any, C extends Client = TClient>(
messageType: string | number,
callback: (client: C, message: T) => void,
);
public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
// public onMessageBytes<T = any, C extends Client = TClient>(
messageType: string | number,
validationSchema: StandardSchemaV1<T>,
callback: (client: C, message: T) => void,
);
public onMessageBytes<T = any>(
_messageType: string | number,
_validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
_callback?: (...args: any[]) => void,
) {
const messageType = `_$b${_messageType}`;
const validationSchema = (typeof _callback === 'function')
? _validationSchema as StandardSchemaV1<T>
: 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<void>
*/
public disconnect(closeCode: number = CloseCode.CONSENTED): Promise<any> {
// 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<void>((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<T> & 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<T> & 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<Client> - The differed is a promise like type.
* This type can forcibly reject the promise by calling `.reject()`.
*
* @example
* ```typescript
* onDrop(client: Client, code: CloseCode) {
* // Allow the client to reconnect into the room with a 15 seconds timeout.
* this.allowReconnection(client, 15);
* }
* ```
*/
public allowReconnection(previousClient: Client, seconds: number | "manual"): Deferred<Client> {
//
// 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<Client & ClientPrivate>();
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\', proce