UNPKG

@colyseus/core

Version:

Multiplayer Framework for Node.js.

1,196 lines (1,194 loc) 47.6 kB
"use strict"; 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 });