@colyseus/core
Version:
Multiplayer Framework for Node.js.
1,196 lines (1,194 loc) • 47.6 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// packages/core/src/Room.ts
var Room_exports = {};
__export(Room_exports, {
DEFAULT_SEAT_RESERVATION_TIME: () => DEFAULT_SEAT_RESERVATION_TIME,
Room: () => Room,
RoomInternalState: () => RoomInternalState,
room: () => room,
validate: () => validate
});
module.exports = __toCommonJS(Room_exports);
var import_msgpackr = require("@colyseus/msgpackr");
var import_schema = require("@colyseus/schema");
var import_timer = require("@colyseus/timer");
var import_events = require("events");
var import_Logger = require("./Logger.cjs");
var import_NoneSerializer = require("./serializer/NoneSerializer.cjs");
var import_SchemaSerializer = require("./serializer/SchemaSerializer.cjs");
var import_Protocol = require("./Protocol.cjs");
var import_Utils = require("./utils/Utils.cjs");
var import_nanoevents = require("./utils/nanoevents.cjs");
var import_DevMode = require("./utils/DevMode.cjs");
var import_Debug = require("./Debug.cjs");
var import_ServerError = require("./errors/ServerError.cjs");
var import_Transport = require("./Transport.cjs");
var import_RoomExceptions = require("./errors/RoomExceptions.cjs");
var import_StandardSchema = require("./utils/StandardSchema.cjs");
var matchMaker = __toESM(require("./MatchMaker.cjs"), 1);
var import_shared_types = require("@colyseus/shared-types");
var DEFAULT_PATCH_RATE = 1e3 / 20;
var DEFAULT_SIMULATION_INTERVAL = 1e3 / 60;
var noneSerializer = new import_NoneSerializer.NoneSerializer();
var DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
function validate(format, handler) {
return { format, handler };
}
var RoomInternalState = {
CREATING: 0,
CREATED: 1,
DISPOSING: 2
};
var Room = class _Room {
constructor() {
/**
* Timing events tied to the room instance.
* Intervals and timeouts are cleared when the room is disposed.
*/
this.clock = new import_timer.ClockTimer();
this.#_onLeaveConcurrent = 0;
// number of onLeave calls in progress
/**
* Maximum number of clients allowed to connect into the room. When room reaches this limit,
* it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,
* the room will be unlocked as soon as a client disconnects from it.
*/
this.maxClients = Infinity;
this.#_maxClientsReached = false;
/**
* Automatically dispose the room when last client disconnects.
*
* @default true
*/
this.autoDispose = true;
/**
* Frequency to send the room state to connected clients, in milliseconds.
*
* @default 50ms (20fps)
*/
this.patchRate = DEFAULT_PATCH_RATE;
/**
* Maximum number of messages a client can send to the server per second.
* If a client sends more messages than this, it will be disconnected.
*
* @default Infinity
*/
this.maxMessagesPerSecond = Infinity;
/**
* The array of connected clients.
*
* @see [Client instance](https://docs.colyseus.io/room#client)
*/
this.clients = new import_Transport.ClientArray();
/**
* Set the number of seconds a room can wait for a client to effectively join the room.
* You should consider how long your `onAuth()` will have to wait for setting a different seat reservation time.
* The default value is 15 seconds. You may set the `COLYSEUS_SEAT_RESERVATION_TIME`
* environment variable if you'd like to change the seat reservation time globally.
*
* @default 15 seconds
*/
this.seatReservationTimeout = DEFAULT_SEAT_RESERVATION_TIME;
this._events = new import_events.EventEmitter();
this._reservedSeats = {};
this._reservedSeatTimeouts = {};
this._reconnections = {};
this._reconnectionAttempts = {};
this.onMessageEvents = (0, import_nanoevents.createNanoEvents)();
this.onMessageValidators = {};
this.onMessageFallbacks = {
"__no_message_handler": (client, messageType, _) => {
const errorMessage = `room onMessage for "${messageType}" not registered.`;
(0, import_Debug.debugMessage)(`${errorMessage} (roomId: ${this.roomId})`);
if (import_DevMode.isDevMode) {
client.error(import_shared_types.ErrorCode.INVALID_PAYLOAD, errorMessage);
} else {
client.leave(import_shared_types.CloseCode.WITH_ERROR, errorMessage);
}
}
};
this._serializer = noneSerializer;
this._afterNextPatchQueue = [];
this._internalState = RoomInternalState.CREATING;
this._lockedExplicitly = false;
this.#_locked = false;
this._events.once("dispose", () => {
this.#_dispose().catch((e) => (0, import_Debug.debugAndPrintError)(`onDispose error: ${e && e.stack || e.message || e || "promise rejected"} (roomId: ${this.roomId})`)).finally(() => this._events.emit("disconnect"));
});
if (this.onUncaughtException !== void 0) {
this.#registerUncaughtExceptionHandlers();
}
}
/**
* This property will change on these situations:
* - The maximum number of allowed clients has been reached (`maxClients`)
* - You manually locked, or unlocked the room using lock() or `unlock()`.
*
* @readonly
*/
get locked() {
return this.#_locked;
}
/**
* Get the room's matchmaking metadata.
*/
get metadata() {
return this._listing.metadata;
}
/**
* Set the room's matchmaking metadata.
*
* **Note**: This setter does NOT automatically persist. Use `setMatchmaking()` for automatic persistence.
*
* @example
* ```typescript
* class MyRoom extends Room<{ metadata: { difficulty: string; rating: number } }> {
* async onCreate() {
* this.metadata = { difficulty: "hard", rating: 1500 };
* }
* }
* ```
*/
set metadata(meta) {
if (this._internalState !== RoomInternalState.CREATING) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.APPLICATION_ERROR, "'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead.");
}
this._listing.metadata = meta;
}
#_roomId;
#_roomName;
#_onLeaveConcurrent;
#_maxClientsReached;
#_maxClients;
#_autoDispose;
#_patchRate;
#_patchInterval;
#_state;
#_locked;
/**
* This method is called by the MatchMaker before onCreate()
* @internal
*/
__init() {
this.#_state = this.state;
this.#_autoDispose = this.autoDispose;
this.#_patchRate = this.patchRate;
this.#_maxClients = this.maxClients;
Object.defineProperties(this, {
state: {
enumerable: true,
get: () => this.#_state,
set: (newState) => {
if (newState?.constructor[Symbol.metadata] !== void 0 || newState[import_schema.$changes] !== void 0) {
this.setSerializer(new import_SchemaSerializer.SchemaSerializer());
} else if ("_definition" in newState) {
throw new Error("@colyseus/schema v2 compatibility currently missing (reach out if you need it)");
} else if (import_schema.$changes === void 0) {
throw new Error("Multiple @colyseus/schema versions detected. Please make sure you don't have multiple versions of @colyseus/schema installed.");
}
this._serializer.reset(newState);
this.#_state = newState;
}
},
maxClients: {
enumerable: true,
get: () => this.#_maxClients,
set: (value) => {
this.setMatchmaking({ maxClients: value });
}
},
autoDispose: {
enumerable: true,
get: () => this.#_autoDispose,
set: (value) => {
if (value !== this.#_autoDispose && this._internalState !== RoomInternalState.DISPOSING) {
this.#_autoDispose = value;
this.resetAutoDisposeTimeout();
}
}
},
patchRate: {
enumerable: true,
get: () => this.#_patchRate,
set: (milliseconds) => {
this.#_patchRate = milliseconds;
if (this.#_patchInterval) {
clearInterval(this.#_patchInterval);
this.#_patchInterval = void 0;
}
if (milliseconds !== null && milliseconds !== 0) {
this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds);
} else if (!this._simulationInterval) {
this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL);
}
}
}
});
this.patchRate = this.#_patchRate;
if (this.#_state) {
this.state = this.#_state;
}
if (this.messages !== void 0) {
if (this.messages["_"]) {
this.onMessage("*", this.messages["_"].bind(this));
delete this.messages["_"];
}
Object.entries(this.messages).forEach(([messageType, callback]) => {
if (typeof callback === "function") {
this.onMessage(messageType, callback.bind(this));
} else {
this.onMessage(messageType, callback.format, callback.handler.bind(this));
}
});
}
this.resetAutoDisposeTimeout(this.seatReservationTimeout);
this.clock.start();
}
/**
* The name of the room you provided as first argument for `gameServer.define()`.
*
* @returns roomName string
*/
get roomName() {
return this.#_roomName;
}
/**
* Setting the name of the room. Overwriting this property is restricted.
*
* @param roomName
*/
set roomName(roomName) {
if (this.#_roomName) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.APPLICATION_ERROR, "'roomName' cannot be overwritten.");
}
this.#_roomName = roomName;
}
/**
* A unique, auto-generated, 9-character-long id of the room.
* You may replace `this.roomId` during `onCreate()`.
*
* @returns roomId string
*/
get roomId() {
return this.#_roomId;
}
/**
* Setting the roomId, is restricted in room lifetime except upon room creation.
*
* @param roomId
* @returns roomId string
*/
set roomId(roomId) {
if (this._internalState !== RoomInternalState.CREATING && !import_DevMode.isDevMode) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation.");
}
this.#_roomId = roomId;
}
/**
* This method is called before onJoin() - this is where you should authenticate the client
* @param client - The client that is authenticating.
* @param options - The options passed to the client when it is authenticating.
* @param context - The authentication context, including the token and the client's IP address.
* @returns The authentication result.
*
* @example
* ```typescript
* return {
* userId: 123,
* username: "John Doe",
* email: "john.doe@example.com",
* };
* ```
*/
onAuth(client, options, context) {
return true;
}
static async onAuth(token, options, context) {
return true;
}
/**
* This method is called during graceful shutdown of the server process
* You may override this method to dispose the room in your own way.
*
* Once process reaches room count of 0, the room process will be terminated.
*/
onBeforeShutdown() {
this.disconnect(
import_DevMode.isDevMode ? import_shared_types.CloseCode.MAY_TRY_RECONNECT : import_shared_types.CloseCode.SERVER_SHUTDOWN
).catch(() => {
});
}
/**
* Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients.
*
* @returns boolean
*/
hasReachedMaxClients() {
return this.clients.length + Object.keys(this._reservedSeats).length >= this.#_maxClients || this._internalState === RoomInternalState.DISPOSING;
}
/**
* @deprecated Use `seatReservationTimeout=` instead.
*/
setSeatReservationTime(seconds) {
console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`);
this.seatReservationTimeout = seconds;
return this;
}
hasReservedSeat(sessionId, reconnectionToken) {
const reservedSeat = this._reservedSeats[sessionId];
if (reservedSeat) {
return (
// not consumed
reservedSeat[2] === false || // reconnection is allowed and the reconnection token is valid.
reservedSeat[3] && this._reconnections[reconnectionToken]?.[0] === sessionId
);
} else if (typeof reconnectionToken === "string") {
return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken;
}
return false;
}
checkReconnectionToken(reconnectionToken) {
const sessionId = this._reconnections[reconnectionToken]?.[0];
const reservedSeat = this._reservedSeats[sessionId];
if (reservedSeat && reservedSeat[3]) {
return sessionId;
}
const client = this.clients.find((client2) => client2.reconnectionToken === reconnectionToken);
if (client) {
this.#_forciblyCloseClient(client, import_shared_types.CloseCode.WITH_ERROR);
return client.sessionId;
}
return void 0;
}
/**
* (Optional) Set a simulation interval that can change the state of the game.
* The simulation interval is your game loop.
*
* @default 16.6ms (60fps)
*
* @param onTickCallback - You can implement your physics or world updates here!
* This is a good place to update the room state.
* @param delay - Interval delay on executing `onTickCallback` in milliseconds.
*/
setSimulationInterval(onTickCallback, delay = DEFAULT_SIMULATION_INTERVAL) {
if (this._simulationInterval) {
clearInterval(this._simulationInterval);
}
if (onTickCallback) {
if (this.onUncaughtException !== void 0) {
onTickCallback = (0, import_Utils.wrapTryCatch)(onTickCallback, this.onUncaughtException.bind(this), import_RoomExceptions.SimulationIntervalException, "setSimulationInterval");
}
this._simulationInterval = setInterval(() => {
this.clock.tick();
onTickCallback(this.clock.deltaTime);
}, delay);
}
}
/**
* @deprecated Use `.patchRate=` instead.
*/
setPatchRate(milliseconds) {
this.patchRate = milliseconds;
}
/**
* @deprecated Use `.state =` instead.
*/
setState(newState) {
this.state = newState;
}
setSerializer(serializer) {
this._serializer = serializer;
}
async setMetadata(meta, persist = true) {
if (!this._listing.metadata) {
this._listing.metadata = meta;
} else {
for (const field in meta) {
if (!meta.hasOwnProperty(field)) {
continue;
}
this._listing.metadata[field] = meta[field];
}
if ("markModified" in this._listing) {
this._listing.markModified("metadata");
}
}
if (persist && this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.persist(this._listing);
this._events.emit("metadata-change");
}
}
async setPrivate(bool = true, persist = true) {
if (this._listing.private === bool) return;
this._listing.private = bool;
if (persist && this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.persist(this._listing);
}
this._events.emit("visibility-change", bool);
}
/**
* Update multiple matchmaking/listing properties at once with a single persist operation.
* This is the recommended way to update room listing properties.
*
* @param updates - Object containing the properties to update
*
* @example
* ```typescript
* // Update multiple properties at once
* await this.setMatchmaking({
* metadata: { difficulty: "hard", rating: 1500 },
* private: true,
* locked: true,
* maxClients: 10
* });
* ```
*
* @example
* ```typescript
* // Update only metadata
* await this.setMatchmaking({
* metadata: { status: "in_progress" }
* });
* ```
*
* @example
* ```typescript
* // Partial metadata update (merges with existing)
* await this.setMatchmaking({
* metadata: { ...this.metadata, round: this.metadata.round + 1 }
* });
* ```
*/
async setMatchmaking(updates) {
for (const key in updates) {
if (!updates.hasOwnProperty(key)) {
continue;
}
switch (key) {
case "metadata": {
this.setMetadata(updates.metadata, false);
break;
}
case "private": {
this.setPrivate(updates.private, false);
break;
}
case "locked": {
if (updates[key]) {
this.lock.call(this, true);
this._lockedExplicitly = true;
} else {
this.unlock.call(this, true);
this._lockedExplicitly = false;
}
break;
}
case "maxClients": {
this.#_maxClients = updates.maxClients;
this._listing.maxClients = updates.maxClients;
const hasReachedMaxClients = this.hasReachedMaxClients();
if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {
this.#_maxClientsReached = false;
this.#_locked = false;
this._listing.locked = false;
updates.locked = false;
}
if (hasReachedMaxClients) {
this.#_maxClientsReached = true;
this.#_locked = true;
this._listing.locked = true;
updates.locked = true;
}
break;
}
case "clients": {
console.warn("setMatchmaking() does not allow updating 'clients' property.");
break;
}
default: {
this._listing[key] = updates[key];
break;
}
}
}
if (this._internalState === RoomInternalState.CREATED) {
await matchMaker.driver.update(this._listing, { $set: updates });
this._events.emit("metadata-change");
}
}
/**
* Lock the room. This prevents new clients from joining this room.
*/
async lock() {
this._lockedExplicitly = arguments[0] === void 0;
if (this.#_locked) {
return;
}
this.#_locked = true;
if (this._lockedExplicitly) {
await matchMaker.driver.update(this._listing, {
$set: { locked: this.#_locked }
});
}
this._events.emit("lock");
}
/**
* Unlock the room. This allows new clients to join this room, if maxClients is not reached.
*/
async unlock() {
if (arguments[0] === void 0) {
this._lockedExplicitly = false;
}
if (!this.#_locked) {
return;
}
this.#_locked = false;
if (arguments[0] === void 0) {
await matchMaker.driver.update(this._listing, {
$set: { locked: this.#_locked }
});
}
this._events.emit("unlock");
}
send(client, messageOrType, messageOrOptions, options) {
import_Logger.logger.warn("DEPRECATION WARNING: use client.send(...) instead of this.send(client, ...)");
client.send(messageOrType, messageOrOptions, options);
}
/**
* Broadcast a message to all connected clients.
* @param type - The type of the message.
* @param message - The message to broadcast.
* @param options - The options for the broadcast.
*
* @example
* ```typescript
* this.broadcast('message', { message: 'Hello, world!' });
* ```
*/
broadcast(type, ...args) {
const [message, options] = args;
if (options && options.afterNextPatch) {
delete options.afterNextPatch;
this._afterNextPatchQueue.push(["broadcast", [type, ...args]]);
return;
}
this.broadcastMessageType(type, message, options);
}
/**
* Broadcast bytes (UInt8Arrays) to a particular room
*/
broadcastBytes(type, message, options) {
if (options && options.afterNextPatch) {
delete options.afterNextPatch;
this._afterNextPatchQueue.push(["broadcastBytes", arguments]);
return;
}
this.broadcastMessageType(type, message, options);
}
/**
* Checks whether mutations have occurred in the state, and broadcast them to all connected clients.
*/
broadcastPatch() {
if (this.onBeforePatch) {
this.onBeforePatch(this.state);
}
if (!this._simulationInterval) {
this.clock.tick();
}
if (!this.state) {
return false;
}
const hasChanges = this._serializer.applyPatches(this.clients, this.state);
this._dequeueAfterPatchMessages();
return hasChanges;
}
onMessage(_messageType, _validationSchema, _callback) {
const messageType = _messageType.toString();
const validationSchema = typeof _callback === "function" ? _validationSchema : void 0;
const callback = validationSchema === void 0 ? _validationSchema : _callback;
const removeListener = this.onMessageEvents.on(messageType, this.onUncaughtException !== void 0 ? (0, import_Utils.wrapTryCatch)(callback, this.onUncaughtException.bind(this), import_RoomExceptions.OnMessageException, "onMessage", false, _messageType) : callback);
if (validationSchema !== void 0) {
this.onMessageValidators[messageType] = validationSchema;
}
return () => {
removeListener();
if (this.onMessageEvents.events[messageType].length === 0) {
delete this.onMessageValidators[messageType];
}
};
}
onMessageBytes(_messageType, _validationSchema, _callback) {
const messageType = `_$b${_messageType}`;
const validationSchema = typeof _callback === "function" ? _validationSchema : void 0;
const callback = validationSchema === void 0 ? _validationSchema : _callback;
if (validationSchema !== void 0) {
return this.onMessage(messageType, validationSchema, callback);
} else {
return this.onMessage(messageType, callback);
}
}
/**
* Disconnect all connected clients, and then dispose the room.
*
* @param closeCode WebSocket close code (default = 4000, which is a "consented leave")
* @returns Promise<void>
*/
disconnect(closeCode = import_shared_types.CloseCode.CONSENTED) {
if (this._internalState === RoomInternalState.DISPOSING) {
return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);
} else if (this._internalState === RoomInternalState.CREATING) {
throw new Error("cannot disconnect during onCreate()");
}
this._internalState = RoomInternalState.DISPOSING;
matchMaker.driver.remove(this._listing.roomId);
this.#_autoDispose = true;
const delayedDisconnection = new Promise((resolve) => this._events.once("disconnect", () => resolve()));
this._rejectPendingReconnections("disconnecting");
let numClients = this.clients.length;
if (numClients > 0) {
while (numClients--) {
this.#_forciblyCloseClient(this.clients[numClients], closeCode);
}
} else {
this._events.emit("dispose");
}
return delayedDisconnection;
}
_rejectPendingReconnections(message) {
for (const [_, reconnection] of Object.values(this._reconnections)) {
reconnection.reject(new import_ServerError.ServerError(import_shared_types.CloseCode.NORMAL_CLOSURE, message));
reconnection.catch(() => {
});
}
}
async _onJoin(client, authContext, connectionOptions) {
const sessionId = client.sessionId;
client.reconnectionToken = (0, import_Utils.generateId)();
if (this._reservedSeatTimeouts[sessionId]) {
clearTimeout(this._reservedSeatTimeouts[sessionId]);
delete this._reservedSeatTimeouts[sessionId];
}
if (this._autoDisposeTimeout) {
clearTimeout(this._autoDisposeTimeout);
this._autoDisposeTimeout = void 0;
}
if (this._reservedSeats[sessionId] === void 0 && connectionOptions?.reconnectionToken && this.clients.getById(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken) {
(0, import_Debug.debugMatchMaking)("attempting to reconnect client with a stale previous connection - sessionId: '%s', roomId: '%s'", client.sessionId, this.roomId);
this._reconnectionAttempts[connectionOptions.reconnectionToken] = new import_Utils.Deferred();
const reconnectionAttemptTimeout = setTimeout(() => {
this._reconnectionAttempts[connectionOptions.reconnectionToken]?.reject(new import_ServerError.ServerError(import_shared_types.CloseCode.MAY_TRY_RECONNECT, "Reconnection attempt timed out"));
}, this.seatReservationTimeout * 1e3);
const cleanup = () => {
clearTimeout(reconnectionAttemptTimeout);
delete this._reconnectionAttempts[connectionOptions.reconnectionToken];
};
await this._reconnectionAttempts[connectionOptions.reconnectionToken].then(() => cleanup()).catch(() => cleanup());
if (!this._reservedSeats[sessionId]) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.MATCHMAKE_EXPIRED, "failed to reconnect");
}
}
const [joinOptions, authData, isConsumed, isWaitingReconnection] = this._reservedSeats[sessionId];
if (isConsumed) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.MATCHMAKE_EXPIRED, "already consumed");
}
this._reservedSeats[sessionId][2] = true;
(0, import_Debug.debugMatchMaking)("consuming seat reservation, sessionId: '%s' (roomId: %s)", client.sessionId, this.roomId);
client._afterNextPatchQueue = this._afterNextPatchQueue;
client.ref["onleave"] = (_) => client.state = import_Transport.ClientState.LEAVING;
client.ref.once("close", client.ref["onleave"]);
if (isWaitingReconnection) {
const reconnectionToken = connectionOptions?.reconnectionToken;
if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
this.clients.push(client);
await this._reconnections[reconnectionToken]?.[1].resolve(client);
try {
if (this.onReconnect) {
await this.onReconnect(client);
}
if (client.readyState !== WebSocket.OPEN) {
throw new Error("reconnection denied");
}
if (client.state === import_Transport.ClientState.RECONNECTING) {
client.state = import_Transport.ClientState.JOINING;
}
} catch (e) {
await this._onLeave(client, import_shared_types.CloseCode.FAILED_TO_RECONNECT);
throw e;
}
} else {
const errorMessage = process.env.NODE_ENV === "production" ? "already consumed" : "bad reconnection token";
throw new import_ServerError.ServerError(import_shared_types.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 import_ServerError.ServerError(import_shared_types.ErrorCode.AUTH_FAILED, "onAuth failed");
}
} catch (e) {
delete this._reservedSeats[sessionId];
await this.#_decrementClientCount();
throw e;
}
}
if (client.state === import_Transport.ClientState.LEAVING) {
throw new import_ServerError.ServerError(import_shared_types.CloseCode.WITH_ERROR, "already disconnected");
}
this.clients.push(client);
Object.defineProperty(this._reservedSeats, sessionId, {
value: this._reservedSeats[sessionId],
enumerable: false
});
if (this.onJoin) {
await this.onJoin(client, joinOptions, client.auth);
}
if (client.state === import_Transport.ClientState.LEAVING) {
throw new import_ServerError.ServerError(import_shared_types.ErrorCode.MATCHMAKE_UNHANDLED, "early_leave");
} else {
delete this._reservedSeats[sessionId];
this._events.emit("join", client);
}
} catch (e) {
await this._onLeave(client, import_shared_types.CloseCode.WITH_ERROR);
delete this._reservedSeats[sessionId];
if (!e.code) {
e.code = import_shared_types.ErrorCode.APPLICATION_ERROR;
}
throw e;
}
}
if (client.state === import_Transport.ClientState.JOINING) {
client.ref.removeListener("close", client.ref["onleave"]);
client.ref["onleave"] = this._onLeave.bind(this, client);
client.ref.once("close", client.ref["onleave"]);
client.ref.on("message", this._onMessage.bind(this, client));
client.raw(import_Protocol.getMessageBytes[import_shared_types.Protocol.JOIN_ROOM](
client.reconnectionToken,
this._serializer.id,
/**
* if skipHandshake is true, we don't need to send the handshake
* (in case client already has handshake data)
*/
connectionOptions?.skipHandshake ? void 0 : this._serializer.handshake && this._serializer.handshake()
));
}
}
/**
* Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.
* If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.
*
* @param client - The client that is allowed to reconnect into the room.
* @param seconds - The time in seconds that the client is allowed to reconnect into the room.
*
* @returns Deferred<Client> - The differed is a promise like type.
* This type can forcibly reject the promise by calling `.reject()`.
*
* @example
* ```typescript
* onDrop(client: Client, code: CloseCode) {
* // Allow the client to reconnect into the room with a 15 seconds timeout.
* this.allowReconnection(client, 15);
* }
* ```
*/
allowReconnection(previousClient, seconds) {
if (previousClient._enqueuedMessages !== void 0) {
return Promise.reject(new import_ServerError.ServerError("not joined"));
}
if (seconds === void 0) {
console.warn('DEPRECATED: allowReconnection() requires a second argument. Using "manual" mode.');
seconds = "manual";
}
if (seconds === "manual") {
seconds = Infinity;
}
if (this._internalState === RoomInternalState.DISPOSING) {
return Promise.reject(new Error("disposing"));
}
const sessionId = previousClient.sessionId;
const reconnectionToken = previousClient.reconnectionToken;
if (this._reconnections[reconnectionToken]) {
(0, import_Debug.debugMatchMaking)("skipping duplicate .allowReconnection() call for client - sessionId: '%s', roomId: '%s'", sessionId, this.roomId);
return this._reconnections[reconnectionToken][1];
}
this._reserveSeat(sessionId, true, previousClient.auth, seconds, true);
const reconnection = new import_Utils.Deferred();
this._reconnections[reconnectionToken] = [sessionId, reconnection];
if (seconds !== Infinity) {
this._reservedSeatTimeouts[sessionId] = setTimeout(() => reconnection.reject(false), seconds * 1e3);
}
const cleanup = () => {
delete this._reconnections[reconnectionToken];
delete this._reservedSeats[sessionId];
delete this._reservedSeatTimeouts[sessionId];
};
reconnection.then((newClient) => {
newClient.auth = previousClient.auth;
newClient.userData = previousClient.userData;
newClient.view = previousClient.view;
newClient.state = import_Transport.ClientState.RECONNECTING;
previousClient.state = import_Transport.ClientState.RECONNECTED;
previousClient.ref = newClient.ref;
previousClient.reconnectionToken = newClient.reconnectionToken;
clearTimeout(this._reservedSeatTimeouts[sessionId]);
}, () => {
this.resetAutoDisposeTimeout();
}).finally(() => {
cleanup();
});
if (this._reconnectionAttempts[reconnectionToken]) {
(0, import_Debug.debugMatchMaking)("resolving reconnection attempt for client - sessionId: '%s', roomId: '%s'", sessionId, this.roomId);
this._reconnectionAttempts[reconnectionToken].resolve(true);
}
return reconnection;
}
resetAutoDisposeTimeout(timeoutInSeconds = 1) {
clearTimeout(this._autoDisposeTimeout);
if (!this.#_autoDispose) {
return;
}
this._autoDisposeTimeout = setTimeout(() => {
this._autoDisposeTimeout = void 0;
this.#_disposeIfEmpty();
}, timeoutInSeconds * 1e3);
}
broadcastMessageType(type, message, options = {}) {
(0, import_Debug.debugMessage)("broadcast: %O (roomId: %s)", message, this.roomId);
const encodedMessage = message instanceof Uint8Array ? import_Protocol.getMessageBytes.raw(import_shared_types.Protocol.ROOM_DATA_BYTES, type, void 0, message) : import_Protocol.getMessageBytes.raw(import_shared_types.Protocol.ROOM_DATA, type, message);
const except = typeof options.except !== "undefined" ? Array.isArray(options.except) ? options.except : [options.except] : void 0;
let numClients = this.clients.length;
while (numClients--) {
const client = this.clients[numClients];
if (!except || !except.includes(client)) {
client.enqueueRaw(encodedMessage);
}
}
}
sendFullState(client) {
client.raw(this._serializer.getFullState(client));
}
_dequeueAfterPatchMessages() {
const length = this._afterNextPatchQueue.length;
if (length > 0) {
for (let i = 0; i < length; i++) {
const [target, args] = this._afterNextPatchQueue[i];
if (target === "broadcast") {
this.broadcast.apply(this, args);
} else {
target.raw.apply(target, args);
}
}
this._afterNextPatchQueue.splice(0, length);
}
}
async _reserveSeat(sessionId, joinOptions = true, authData = void 0, seconds = this.seatReservationTimeout, allowReconnection = false, devModeReconnectionToken) {
if (!allowReconnection && this.hasReachedMaxClients()) {
return false;
}
(0, import_Debug.debugMatchMaking)(
"reserving seat on '%s' - sessionId: '%s', roomId: '%s', processId: '%s'",
this.roomName,
sessionId,
this.roomId,
matchMaker.processId
);
this._reservedSeats[sessionId] = [joinOptions, authData, false, allowReconnection];
if (!allowReconnection) {
await this.#_incrementClientCount();
this._reservedSeatTimeouts[sessionId] = setTimeout(async () => {
delete this._reservedSeats[sessionId];
delete this._reservedSeatTimeouts[sessionId];
await this.#_decrementClientCount();
}, seconds * 1e3);
this.resetAutoDisposeTimeout(seconds);
}
if (devModeReconnectionToken) {
const reconnection = new import_Utils.Deferred();
this._reconnections[devModeReconnectionToken] = [sessionId, reconnection];
clearTimeout(this._reservedSeatTimeouts[sessionId]);
this._reservedSeatTimeouts[sessionId] = setTimeout(async () => {
if (!this._reconnections[devModeReconnectionToken]) {
return;
}
delete this._reconnections[devModeReconnectionToken];
delete this._reservedSeats[sessionId];
delete this._reservedSeatTimeouts[sessionId];
if (!allowReconnection) {
await this.#_decrementClientCount();
}
this.onLeave?.({ sessionId }, import_shared_types.CloseCode.MAY_TRY_RECONNECT);
}, seconds * 1e3);
}
return true;
}
async _reserveMultipleSeats(multipleSessionIds, multipleJoinOptions = true, multipleAuthData = void 0, seconds = this.seatReservationTimeout) {
let promises = [];
for (let i = 0; i < multipleSessionIds.length; i++) {
promises.push(this._reserveSeat(multipleSessionIds[i], multipleJoinOptions[i], multipleAuthData[i], seconds));
}
return await Promise.all(promises);
}
#_disposeIfEmpty() {
const willDispose = this.#_onLeaveConcurrent === 0 && // no "onLeave" calls in progress
this.#_autoDispose && this._autoDisposeTimeout === void 0 && this.clients.length === 0 && Object.keys(this._reservedSeats).length === 0;
if (willDispose) {
this._events.emit("dispose");
}
return willDispose;
}
async #_dispose() {
this._internalState = RoomInternalState.DISPOSING;
if (this._listing?.roomId !== void 0) {
await matchMaker.driver.remove(this._listing.roomId);
}
let userReturnData;
if (this.onDispose) {
userReturnData = this.onDispose();
}
if (this.#_patchInterval) {
clearInterval(this.#_patchInterval);
this.#_patchInterval = void 0;
}
if (this._simulationInterval) {
clearInterval(this._simulationInterval);
this._simulationInterval = void 0;
}
if (this._autoDisposeTimeout) {
clearInterval(this._autoDisposeTimeout);
this._autoDisposeTimeout = void 0;
}
this.clock.clear();
this.clock.stop();
return await (userReturnData || Promise.resolve());
}
_onMessage(client, buffer) {
if (client.state === import_Transport.ClientState.LEAVING) {
return;
}
if (!buffer) {
(0, import_Debug.debugAndPrintError)(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`);
return;
}
if (this.clock.currentTime - client._lastMessageTime >= 1e3) {
client._numMessagesLastSecond = 0;
client._lastMessageTime = this.clock.currentTime;
} else if (++client._numMessagesLastSecond > this.maxMessagesPerSecond) {
(0, import_Debug.debugMatchMaking)("dropping client - sessionId: '%s' (roomId: %s), too many messages per second", client.sessionId, this.roomId);
return this.#_forciblyCloseClient(client, import_shared_types.CloseCode.WITH_ERROR);
}
const it = { offset: 1 };
const code = buffer[0];
if (code === import_shared_types.Protocol.ROOM_DATA) {
const messageType = import_schema.decode.stringCheck(buffer, it) ? import_schema.decode.string(buffer, it) : import_schema.decode.number(buffer, it);
let message;
try {
message = buffer.byteLength > it.offset ? (0, import_msgpackr.unpack)(buffer.subarray(it.offset, buffer.byteLength)) : void 0;
(0, import_Debug.debugMessage)("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
if (this.onMessageValidators[messageType] !== void 0) {
message = (0, import_StandardSchema.standardValidate)(this.onMessageValidators[messageType], message);
}
} catch (e) {
(0, import_Debug.debugAndPrintError)(e);
client.leave(import_shared_types.CloseCode.WITH_ERROR);
return;
}
if (this.onMessageEvents.events[messageType]) {
this.onMessageEvents.emit(messageType, client, message);
} else if (this.onMessageEvents.events["*"]) {
this.onMessageEvents.emit("*", client, messageType, message);
} else {
this.onMessageFallbacks["__no_message_handler"](client, messageType, message);
}
} else if (code === import_shared_types.Protocol.ROOM_DATA_BYTES) {
const messageType = import_schema.decode.stringCheck(buffer, it) ? import_schema.decode.string(buffer, it) : import_schema.decode.number(buffer, it);
let message = buffer.subarray(it.offset, buffer.byteLength);
(0, import_Debug.debugMessage)("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
const bytesMessageType = `_$b${messageType}`;
try {
if (this.onMessageValidators[bytesMessageType] !== void 0) {
message = (0, import_StandardSchema.standardValidate)(this.onMessageValidators[bytesMessageType], message);
}
} catch (e) {
(0, import_Debug.debugAndPrintError)(e);
client.leave(import_shared_types.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 === import_shared_types.Protocol.JOIN_ROOM && client.state === import_Transport.ClientState.JOINING) {
client.state = import_Transport.ClientState.JOINED;
client._joinedAt = this.clock.elapsedTime;
if (this.state) {
this.sendFullState(client);
}
if (client._enqueuedMessages.length > 0) {
client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
}
delete client._enqueuedMessages;
} else if (code === import_shared_types.Protocol.PING) {
client.raw(import_Protocol.getMessageBytes[import_shared_types.Protocol.PING]());
} else if (code === import_shared_types.Protocol.LEAVE_ROOM) {
this.#_forciblyCloseClient(client, import_shared_types.CloseCode.CONSENTED);
}
}
#_forciblyCloseClient(client, closeCode) {
client.ref.removeAllListeners("message");
client.ref.removeListener("close", client.ref["onleave"]);
this._onLeave(client, closeCode).then(() => client.leave(closeCode));
}
async _onLeave(client, code) {
const method = code === import_shared_types.CloseCode.CONSENTED || client.state === import_Transport.ClientState.RECONNECTING ? this.onLeave : this.onDrop || this.onLeave;
client.state = import_Transport.ClientState.LEAVING;
if (!this.clients.delete(client)) {
return;
}
if (method) {
(0, import_Debug.debugMatchMaking)(`${method.name}, sessionId: '%s' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
try {
this.#_onLeaveConcurrent++;
await method.call(this, client, code);
} catch (e) {
const serverError = !(e instanceof import_ServerError.ServerError) ? new import_ServerError.ServerError(import_shared_types.CloseCode.WITH_ERROR, `${method.name} error`, { cause: e }) : e;
(0, import_Debug.debugAndPrintError)(serverError);
} finally {
this.#_onLeaveConcurrent--;
}
}
if (this._reconnections[client.reconnectionToken]) {
this._reconnections[client.reconnectionToken][1].catch(async () => {
await this.#_onAfterLeave(client, code, method === this.onDrop);
});
} else if (client.state !== import_Transport.ClientState.RECONNECTED) {
await this.#_onAfterLeave(client, code, method === this.onDrop);
}
}
async #_onAfterLeave(client, code, isDrop = false) {
if (isDrop && this.onLeave) {
await this.onLeave(client, code);
}
const willDispose = await this.#_decrementClientCount();
if (this._reservedSeats[client.sessionId] === void 0) {
this._events.emit("leave", client, willDispose);
}
}
async #_incrementClientCount() {
if (!this.#_locked && this.hasReachedMaxClients()) {
this.#_maxClientsReached = true;
this.lock.call(this, true);
}
await matchMaker.driver.update(this._listing, {
$inc: { clients: 1 },
$set: { locked: this.#_locked }
});
}
async #_decrementClientCount() {
const willDispose = this.#_disposeIfEmpty();
if (this._internalState === RoomInternalState.DISPOSING) {
return true;
}
if (!willDispose) {
if (this.#_maxClientsReached && !this._lockedExplicitly) {
this.#_maxClientsReached = false;
this.unlock.call(this, true);
}
await matchMaker.driver.update(this._listing, {
$inc: { clients: -1 },
$set: { locked: this.#_locked }
});
}
return willDispose;
}
#registerUncaughtExceptionHandlers() {
const onUncaughtException = this.onUncaughtException.bind(this);
const originalSetTimeout = this.clock.setTimeout;
this.clock.setTimeout = (cb, timeout, ...args) => {
return originalSetTimeout.call(this.clock, (0, import_Utils.wrapTryCatch)(cb, onUncaughtException, import_RoomExceptions.TimedEventException, "setTimeout"), timeout, ...args);
};
const originalSetInterval = this.clock.setInterval;
this.clock.setInterval = (cb, timeout, ...args) => {
return originalSetInterval.call(this.clock, (0, import_Utils.wrapTryCatch)(cb, onUncaughtException, import_RoomExceptions.TimedEventException, "setInterval"), timeout, ...args);
};
if (this.onCreate !== void 0) {
this.onCreate = (0, import_Utils.wrapTryCatch)(this.onCreate.bind(this), onUncaughtException, import_RoomExceptions.OnCreateException, "onCreate", true);
}
if (this.onAuth !== void 0) {
this.onAuth = (0, import_Utils.wrapTryCatch)(this.onAuth.bind(this), onUncaughtException, import_RoomExceptions.OnAuthException, "onAuth", true);
}
if (this.onJoin !== void 0) {
this.onJoin = (0, import_Utils.wrapTryCatch)(this.onJoin.bind(this), onUncaughtException, import_RoomExceptions.OnJoinException, "onJoin", true);
}
if (this.onLeave !== void 0) {
this.onLeave = (0, import_Utils.wrapTryCatch)(this.onLeave.bind(this), onUncaughtException, import_RoomExceptions.OnLeaveException, "onLeave", true);
}
if (this.onDrop !== void 0) {
this.onDrop = (0, import_Utils.wrapTryCatch)(this.onDrop.bind(this), onUncaughtException, import_RoomExceptions.OnDropException, "onDrop", true);
}
if (this.onReconnect !== void 0) {
this.onReconnect = (0, import_Utils.wrapTryCatch)(this.onReconnect.bind(this), onUncaughtException, import_RoomExceptions.OnReconnectException, "onReconnect", true);
}
if (this.onDispose !== void 0) {
this.onDispose = (0, import_Utils.wrapTryCatch)(this.onDispose.bind(this), onUncaughtException, import_RoomExceptions.OnDisposeException, "onDispose");
}
}
};
function room(options) {
class _ extends Room {
constructor() {
super();
this.messages = options.messages;
if (options.state && typeof options.state === "function") {
this.state = options.state();
}
}
}
for (const key in options) {
if (typeof options[key] === "function") {
_.prototype[key] = options[key];
}
}
return _;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
DEFAULT_SEAT_RESERVATION_TIME,
Room,
RoomInternalState,
room,
validate
});