@colyseus/core 0.17.0 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/build/rooms/RankedQueueRoom.js.map +2 -2
  2. package/build/rooms/RankedQueueRoom.mjs.map +2 -2
  3. package/package.json +7 -6
  4. package/src/Debug.ts +37 -0
  5. package/src/IPC.ts +124 -0
  6. package/src/Logger.ts +30 -0
  7. package/src/MatchMaker.ts +1119 -0
  8. package/src/Protocol.ts +160 -0
  9. package/src/Room.ts +1797 -0
  10. package/src/Server.ts +325 -0
  11. package/src/Stats.ts +107 -0
  12. package/src/Transport.ts +207 -0
  13. package/src/errors/RoomExceptions.ts +141 -0
  14. package/src/errors/SeatReservationError.ts +5 -0
  15. package/src/errors/ServerError.ts +17 -0
  16. package/src/index.ts +81 -0
  17. package/src/matchmaker/Lobby.ts +68 -0
  18. package/src/matchmaker/LocalDriver/LocalDriver.ts +92 -0
  19. package/src/matchmaker/LocalDriver/Query.ts +94 -0
  20. package/src/matchmaker/RegisteredHandler.ts +172 -0
  21. package/src/matchmaker/controller.ts +64 -0
  22. package/src/matchmaker/driver.ts +191 -0
  23. package/src/presence/LocalPresence.ts +331 -0
  24. package/src/presence/Presence.ts +263 -0
  25. package/src/rooms/LobbyRoom.ts +135 -0
  26. package/src/rooms/RankedQueueRoom.ts +425 -0
  27. package/src/rooms/RelayRoom.ts +90 -0
  28. package/src/router/default_routes.ts +58 -0
  29. package/src/router/index.ts +43 -0
  30. package/src/serializer/NoneSerializer.ts +16 -0
  31. package/src/serializer/SchemaSerializer.ts +194 -0
  32. package/src/serializer/SchemaSerializerDebug.ts +148 -0
  33. package/src/serializer/Serializer.ts +9 -0
  34. package/src/utils/DevMode.ts +133 -0
  35. package/src/utils/StandardSchema.ts +20 -0
  36. package/src/utils/Utils.ts +169 -0
  37. package/src/utils/nanoevents.ts +20 -0
package/src/Room.ts ADDED
@@ -0,0 +1,1797 @@
1
+ import { unpack } from '@colyseus/msgpackr';
2
+ import { decode, type Iterator, $changes } from '@colyseus/schema';
3
+ import { ClockTimer as Clock } from '@colyseus/timer';
4
+
5
+ import { EventEmitter } from 'events';
6
+ import { logger } from './Logger.ts';
7
+
8
+ import type { Presence } from './presence/Presence.ts';
9
+ import type { Serializer } from './serializer/Serializer.ts';
10
+ import type { IRoomCache } from './matchmaker/driver.ts';
11
+
12
+ import { NoneSerializer } from './serializer/NoneSerializer.ts';
13
+ import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
14
+
15
+ import { CloseCode, ErrorCode, getMessageBytes, Protocol } from './Protocol.ts';
16
+ import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
17
+ import { createNanoEvents } from './utils/nanoevents.ts';
18
+ import { isDevMode } from './utils/DevMode.ts';
19
+
20
+ import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
21
+ import { ServerError } from './errors/ServerError.ts';
22
+ import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
23
+ import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnJoinException, OnLeaveException, OnMessageException, type RoomException, SimulationIntervalException, TimedEventException } from './errors/RoomExceptions.ts';
24
+
25
+ import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
26
+ import { matchMaker } from '@colyseus/core';
27
+
28
+ const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
29
+ const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
30
+ const noneSerializer = new NoneSerializer();
31
+
32
+ export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
33
+
34
+ export type SimulationCallback = (deltaTime: number) => void;
35
+
36
+ export interface RoomOptions {
37
+ state?: object;
38
+ metadata?: any;
39
+ client?: Client;
40
+ }
41
+
42
+ // Helper types to extract individual properties from RoomOptions
43
+ export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
44
+ export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
45
+ export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
46
+
47
+ export interface IBroadcastOptions extends ISendOptions {
48
+ except?: Client | Client[];
49
+ }
50
+
51
+ /**
52
+ * Message handler with automatic type inference from format schema.
53
+ * When a format is provided, the message type is automatically inferred from the schema.
54
+ */
55
+ export type MessageHandlerWithFormat<T extends StandardSchemaV1 = any, This = any> = {
56
+ format: T;
57
+ handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void;
58
+ };
59
+
60
+ export type MessageHandler<This = any> =
61
+ | ((this: This, client: Client, message: any) => void)
62
+ | MessageHandlerWithFormat<any, This>;
63
+
64
+ /**
65
+ * Extract the message payload type from a message handler.
66
+ * Works with both function handlers and format handlers.
67
+ *
68
+ * (Imported from @colyseus/sdk, not used in the server-side)
69
+ */
70
+ export type ExtractMessageType<T> =
71
+ T extends { format: infer Format extends StandardSchemaV1; handler: any }
72
+ ? StandardSchemaV1.InferOutput<Format>
73
+ : T extends (this: any, client: any, message: infer Message) => void
74
+ ? Message
75
+ : any;
76
+
77
+ /**
78
+ * A map of message types to message handlers.
79
+ */
80
+ export type Messages<This extends Room> = Record<string, MessageHandler<This>>;
81
+
82
+ /**
83
+ * Helper function to create a validated message handler with automatic type inference.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * messages = {
88
+ * move: validate(z.object({ x: z.number(), y: z.number() }), (client, message) => {
89
+ * // message.x and message.y are automatically typed as numbers
90
+ * console.log(message.x, message.y);
91
+ * })
92
+ * }
93
+ * ```
94
+ */
95
+ export function validate<T extends StandardSchemaV1, This = any>(
96
+ format: T,
97
+ handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void
98
+ ): MessageHandlerWithFormat<T, This> {
99
+ return { format, handler };
100
+ }
101
+
102
+ export const RoomInternalState = {
103
+ CREATING: 0,
104
+ CREATED: 1,
105
+ DISPOSING: 2,
106
+ } as const;
107
+ export type RoomInternalState = (typeof RoomInternalState)[keyof typeof RoomInternalState];
108
+
109
+ export type OnCreateOptions<T extends Type<Room>> = Parameters<NonNullable<InstanceType<T>['onCreate']>>[0];
110
+
111
+ /**
112
+ * A Room class is meant to implement a game session, and/or serve as the communication channel
113
+ * between a group of clients.
114
+ *
115
+ * - Rooms are created on demand during matchmaking by default
116
+ * - Room classes must be exposed using `.define()`
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * class MyRoom extends Room<{
121
+ * state: MyState,
122
+ * metadata: { difficulty: string },
123
+ * client: MyClient
124
+ * }> {
125
+ * // ...
126
+ * }
127
+ * ```
128
+ */
129
+ export class Room<T extends RoomOptions = RoomOptions> {
130
+ '~client': ExtractRoomClient<T>;
131
+ '~state': ExtractRoomState<T>;
132
+ '~metadata': ExtractRoomMetadata<T>;
133
+
134
+ /**
135
+ * This property will change on these situations:
136
+ * - The maximum number of allowed clients has been reached (`maxClients`)
137
+ * - You manually locked, or unlocked the room using lock() or `unlock()`.
138
+ *
139
+ * @readonly
140
+ */
141
+ public get locked() {
142
+ return this.#_locked;
143
+ }
144
+
145
+ /**
146
+ * Get the room's matchmaking metadata.
147
+ */
148
+ public get metadata(): ExtractRoomMetadata<T> {
149
+ return this._listing.metadata;
150
+ }
151
+
152
+ /**
153
+ * Set the room's matchmaking metadata.
154
+ *
155
+ * **Note**: This setter does NOT automatically persist. Use `setMatchmaking()` for automatic persistence.
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * class MyRoom extends Room<{ metadata: { difficulty: string; rating: number } }> {
160
+ * async onCreate() {
161
+ * this.metadata = { difficulty: "hard", rating: 1500 };
162
+ * }
163
+ * }
164
+ * ```
165
+ */
166
+ public set metadata(meta: ExtractRoomMetadata<T>) {
167
+ if (this._internalState !== RoomInternalState.CREATING) {
168
+ // prevent user from setting metadata after room has been created.
169
+ throw new ServerError(ErrorCode.APPLICATION_ERROR, "'metadata' can only be manually set during onCreate(). Use setMatchmaking() instead.");
170
+ }
171
+
172
+ this._listing.metadata = meta;
173
+ }
174
+
175
+ /**
176
+ * The room listing cache for matchmaking.
177
+ * @internal
178
+ */
179
+ private _listing: IRoomCache<ExtractRoomMetadata<T>>;
180
+
181
+ /**
182
+ * Timing events tied to the room instance.
183
+ * Intervals and timeouts are cleared when the room is disposed.
184
+ */
185
+ public clock: Clock = new Clock();
186
+
187
+ #_roomId: string;
188
+ #_roomName: string;
189
+ #_onLeaveConcurrent: number = 0; // number of onLeave calls in progress
190
+
191
+ /**
192
+ * Maximum number of clients allowed to connect into the room. When room reaches this limit,
193
+ * it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,
194
+ * the room will be unlocked as soon as a client disconnects from it.
195
+ */
196
+ public maxClients: number = Infinity;
197
+ #_maxClientsReached: boolean = false;
198
+ #_maxClients: number;
199
+
200
+ /**
201
+ * Automatically dispose the room when last client disconnects.
202
+ *
203
+ * @default true
204
+ */
205
+ public autoDispose: boolean = true;
206
+ #_autoDispose: boolean;
207
+
208
+ /**
209
+ * Frequency to send the room state to connected clients, in milliseconds.
210
+ *
211
+ * @default 50ms (20fps)
212
+ */
213
+ public patchRate: number | null = DEFAULT_PATCH_RATE;
214
+ #_patchRate: number;
215
+ #_patchInterval: NodeJS.Timeout;
216
+
217
+ /**
218
+ * Maximum number of messages a client can send to the server per second.
219
+ * If a client sends more messages than this, it will be disconnected.
220
+ *
221
+ * @default 60
222
+ */
223
+ public maxMessagesPerSecond: number = 60;
224
+
225
+ /**
226
+ * The state instance you provided to `setState()`.
227
+ */
228
+ public state: ExtractRoomState<T>;
229
+ #_state: ExtractRoomState<T>;
230
+
231
+ /**
232
+ * The presence instance. Check Presence API for more details.
233
+ *
234
+ * @see [Presence API](https://docs.colyseus.io/server/presence)
235
+ */
236
+ public presence: Presence;
237
+
238
+ /**
239
+ * The array of connected clients.
240
+ *
241
+ * @see [Client instance](https://docs.colyseus.io/room#client)
242
+ */
243
+ public clients: ClientArray<ExtractRoomClient<T>> = new ClientArray();
244
+
245
+ /**
246
+ * Set the number of seconds a room can wait for a client to effectively join the room.
247
+ * You should consider how long your `onAuth()` will have to wait for setting a different seat reservation time.
248
+ * The default value is 15 seconds. You may set the `COLYSEUS_SEAT_RESERVATION_TIME`
249
+ * environment variable if you'd like to change the seat reservation time globally.
250
+ *
251
+ * @default 15 seconds
252
+ */
253
+ public seatReservationTimeout: number = DEFAULT_SEAT_RESERVATION_TIME;
254
+
255
+ private _events = new EventEmitter();
256
+
257
+ private _reservedSeats: { [sessionId: string]: [any, any, boolean?, boolean?] } = {};
258
+ private _reservedSeatTimeouts: { [sessionId: string]: NodeJS.Timeout } = {};
259
+
260
+ private _reconnections: { [reconnectionToken: string]: [string, Deferred] } = {};
261
+
262
+ public messages?: Messages<any>;
263
+
264
+ private onMessageEvents = createNanoEvents();
265
+ private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
266
+
267
+ private onMessageFallbacks = {
268
+ '__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {
269
+ const errorMessage = `room onMessage for "${messageType}" not registered.`;
270
+ debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
271
+
272
+ if (isDevMode) {
273
+ // send error code to client in development mode
274
+ client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
275
+
276
+ } else {
277
+ // immediately close the connection in production
278
+ client.leave(CloseCode.WITH_ERROR, errorMessage);
279
+ }
280
+ }
281
+ };
282
+
283
+ private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
284
+ private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];
285
+
286
+ private _simulationInterval: NodeJS.Timeout;
287
+
288
+ private _internalState: RoomInternalState = RoomInternalState.CREATING;
289
+
290
+ private _lockedExplicitly: boolean = false;
291
+ #_locked: boolean = false;
292
+
293
+ // this timeout prevents rooms that are created by one process, but no client
294
+ // ever had success joining into it on the specified interval.
295
+ private _autoDisposeTimeout: NodeJS.Timeout;
296
+
297
+ constructor() {
298
+ this._events.once('dispose', () => {
299
+ this.#_dispose()
300
+ .catch((e) => debugAndPrintError(`onDispose error: ${(e && e.stack || e.message || e || 'promise rejected')} (roomId: ${this.roomId})`))
301
+ .finally(() => this._events.emit('disconnect'));
302
+ });
303
+
304
+ /**
305
+ * If `onUncaughtException` is defined, it will automatically catch exceptions
306
+ */
307
+ if (this.onUncaughtException !== undefined) {
308
+ this.#registerUncaughtExceptionHandlers();
309
+ }
310
+ }
311
+
312
+ /**
313
+ * This method is called by the MatchMaker before onCreate()
314
+ * @internal
315
+ */
316
+ private __init() {
317
+ this.#_state = this.state;
318
+ this.#_autoDispose = this.autoDispose;
319
+ this.#_patchRate = this.patchRate;
320
+ this.#_maxClients = this.maxClients;
321
+
322
+ Object.defineProperties(this, {
323
+ state: {
324
+ enumerable: true,
325
+ get: () => this.#_state,
326
+ set: (newState: ExtractRoomState<T>) => {
327
+ if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) {
328
+ this.setSerializer(new SchemaSerializer());
329
+ } else if ('_definition' in newState) {
330
+ throw new Error("@colyseus/schema v2 compatibility currently missing (reach out if you need it)");
331
+ } else if ($changes === undefined) {
332
+ throw new Error("Multiple @colyseus/schema versions detected. Please make sure you don't have multiple versions of @colyseus/schema installed.");
333
+ }
334
+ this._serializer.reset(newState);
335
+ this.#_state = newState;
336
+ },
337
+ },
338
+
339
+ maxClients: {
340
+ enumerable: true,
341
+ get: () => this.#_maxClients,
342
+ set: (value: number) => {
343
+ this.setMatchmaking({ maxClients: value });
344
+ },
345
+ },
346
+
347
+ autoDispose: {
348
+ enumerable: true,
349
+ get: () => this.#_autoDispose,
350
+ set: (value: boolean) => {
351
+ if (
352
+ value !== this.#_autoDispose &&
353
+ this._internalState !== RoomInternalState.DISPOSING
354
+ ) {
355
+ this.#_autoDispose = value;
356
+ this.resetAutoDisposeTimeout();
357
+ }
358
+ },
359
+ },
360
+
361
+ patchRate: {
362
+ enumerable: true,
363
+ get: () => this.#_patchRate,
364
+ set: (milliseconds: number) => {
365
+ this.#_patchRate = milliseconds;
366
+ // clear previous interval in case called setPatchRate more than once
367
+ if (this.#_patchInterval) {
368
+ clearInterval(this.#_patchInterval);
369
+ this.#_patchInterval = undefined;
370
+ }
371
+ if (milliseconds !== null && milliseconds !== 0) {
372
+ this.#_patchInterval = setInterval(() => this.broadcastPatch(), milliseconds);
373
+ } else if (!this._simulationInterval) {
374
+ // When patchRate and no simulation interval are both set to 0, tick the clock to keep timers working
375
+ this.#_patchInterval = setInterval(() => this.clock.tick(), DEFAULT_SIMULATION_INTERVAL);
376
+ }
377
+ },
378
+ },
379
+ });
380
+
381
+ // set patch interval, now with the setter
382
+ this.patchRate = this.#_patchRate;
383
+
384
+ // set state, now with the setter
385
+ if (this.#_state) {
386
+ this.state = this.#_state;
387
+ }
388
+
389
+ // Bind messages to the room
390
+ if (this.messages !== undefined) {
391
+ Object.entries(this.messages).forEach(([messageType, callback]) => {
392
+ if (typeof callback === 'function') {
393
+ // Direct handler function - bind to room instance
394
+ this.onMessage(messageType, callback.bind(this));
395
+ } else {
396
+ // Object with format and handler - bind handler to room instance
397
+ this.onMessage(messageType, callback.format, callback.handler.bind(this));
398
+ }
399
+ });
400
+ }
401
+
402
+ // set default _autoDisposeTimeout
403
+ this.resetAutoDisposeTimeout(this.seatReservationTimeout);
404
+
405
+ this.clock.start();
406
+ }
407
+
408
+ /**
409
+ * The name of the room you provided as first argument for `gameServer.define()`.
410
+ *
411
+ * @returns roomName string
412
+ */
413
+ public get roomName() { return this.#_roomName; }
414
+ /**
415
+ * Setting the name of the room. Overwriting this property is restricted.
416
+ *
417
+ * @param roomName
418
+ */
419
+ public set roomName(roomName: string) {
420
+ if (this.#_roomName) {
421
+ // prevent user from setting roomName after it has been defined.
422
+ throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomName' cannot be overwritten.");
423
+ }
424
+ this.#_roomName = roomName;
425
+ }
426
+
427
+ /**
428
+ * A unique, auto-generated, 9-character-long id of the room.
429
+ * You may replace `this.roomId` during `onCreate()`.
430
+ *
431
+ * @returns roomId string
432
+ */
433
+ public get roomId() { return this.#_roomId; }
434
+
435
+ /**
436
+ * Setting the roomId, is restricted in room lifetime except upon room creation.
437
+ *
438
+ * @param roomId
439
+ * @returns roomId string
440
+ */
441
+ public set roomId(roomId: string) {
442
+ if (this._internalState !== RoomInternalState.CREATING && !isDevMode) {
443
+ // prevent user from setting roomId after room has been created.
444
+ throw new ServerError(ErrorCode.APPLICATION_ERROR, "'roomId' can only be overridden upon room creation.");
445
+ }
446
+ this.#_roomId = roomId;
447
+ }
448
+
449
+ // Optional abstract methods
450
+
451
+ /**
452
+ * This method is called before the latest version of the room's state is broadcasted to all clients.
453
+ */
454
+ public onBeforePatch?(state: ExtractRoomState<T>): void | Promise<any>;
455
+
456
+ /**
457
+ * This method is called when the room is created.
458
+ * @param options - The options passed to the room when it is created.
459
+ */
460
+ public onCreate?(options: any): void | Promise<any>;
461
+
462
+ /**
463
+ * This method is called when a client joins the room.
464
+ * @param client - The client that joined the room.
465
+ * @param options - The options passed to the client when it joined the room.
466
+ */
467
+ public onJoin?(client: ExtractRoomClient<T>, options?: any): void | Promise<any>;
468
+
469
+ /**
470
+ * This method is called when a client leaves the room without consent.
471
+ * You may allow the client to reconnect by calling `allowReconnection` within this method.
472
+ *
473
+ * @param client - The client that was dropped from the room.
474
+ * @param code - The close code of the leave event.
475
+ */
476
+ public onDrop?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
477
+
478
+ /**
479
+ * This method is called when a client reconnects to the room.
480
+ * @param client - The client that reconnected to the room.
481
+ */
482
+ public onReconnect?(client: ExtractRoomClient<T>): void | Promise<any>;
483
+
484
+ /**
485
+ * This method is called when a client effectively leaves the room.
486
+ * @param client - The client that left the room.
487
+ * @param code - The close code of the leave event.
488
+ */
489
+ public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
490
+
491
+ /**
492
+ * This method is called when the room is disposed.
493
+ */
494
+ public onDispose?(): void | Promise<any>;
495
+
496
+ /**
497
+ * Define a custom exception handler.
498
+ * If defined, all lifecycle hooks will be wrapped by try/catch, and the exception will be forwarded to this method.
499
+ *
500
+ * These methods will be wrapped by try/catch:
501
+ * - `onMessage`
502
+ * - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
503
+ * - `clock.setTimeout` / `clock.setInterval`
504
+ * - `setSimulationInterval`
505
+ *
506
+ * (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
507
+ */
508
+ public onUncaughtException?(error: RoomException, methodName: RoomMethodName): void;
509
+
510
+ /**
511
+ * This method is called before onJoin() - this is where you should authenticate the client
512
+ * @param client - The client that is authenticating.
513
+ * @param options - The options passed to the client when it is authenticating.
514
+ * @param context - The authentication context, including the token and the client's IP address.
515
+ * @returns The authentication result.
516
+ *
517
+ * @example
518
+ * ```typescript
519
+ * return {
520
+ * userId: 123,
521
+ * username: "John Doe",
522
+ * email: "john.doe@example.com",
523
+ * };
524
+ * ```
525
+ */
526
+ public onAuth(
527
+ client: Client,
528
+ options: any,
529
+ context: AuthContext
530
+ ): any | Promise<any> {
531
+ return true;
532
+ }
533
+
534
+ static async onAuth(
535
+ token: string,
536
+ options: any,
537
+ context: AuthContext
538
+ ): Promise<unknown> {
539
+ return true;
540
+ }
541
+
542
+ /**
543
+ * This method is called during graceful shutdown of the server process
544
+ * You may override this method to dispose the room in your own way.
545
+ *
546
+ * Once process reaches room count of 0, the room process will be terminated.
547
+ */
548
+ public onBeforeShutdown() {
549
+ this.disconnect(
550
+ (isDevMode)
551
+ ? CloseCode.DEVMODE_RESTART
552
+ : CloseCode.SERVER_SHUTDOWN
553
+ );
554
+ }
555
+
556
+ /**
557
+ * devMode: When `devMode` is enabled, `onCacheRoom` method is called during
558
+ * graceful shutdown.
559
+ *
560
+ * Implement this method to return custom data to be cached. `onRestoreRoom`
561
+ * will be called with the data returned by `onCacheRoom`
562
+ */
563
+ public onCacheRoom?(): any;
564
+
565
+ /**
566
+ * devMode: When `devMode` is enabled, `onRestoreRoom` method is called during
567
+ * process startup, with the data returned by the `onCacheRoom` method.
568
+ */
569
+ public onRestoreRoom?(cached?: any): void;
570
+
571
+ /**
572
+ * Returns whether the sum of connected clients and reserved seats exceeds maximum number of clients.
573
+ *
574
+ * @returns boolean
575
+ */
576
+ public hasReachedMaxClients(): boolean {
577
+ return (
578
+ (this.clients.length + Object.keys(this._reservedSeats).length) >= this.#_maxClients ||
579
+ this._internalState === RoomInternalState.DISPOSING
580
+ );
581
+ }
582
+
583
+ /**
584
+ * @deprecated Use `seatReservationTimeout=` instead.
585
+ */
586
+ public setSeatReservationTime(seconds: number) {
587
+ console.warn(`DEPRECATED: .setSeatReservationTime(${seconds}) is deprecated. Assign a .seatReservationTimeout property value instead.`);
588
+ this.seatReservationTimeout = seconds;
589
+ return this;
590
+ }
591
+
592
+ public hasReservedSeat(sessionId: string, reconnectionToken?: string): boolean {
593
+ const reservedSeat = this._reservedSeats[sessionId];
594
+
595
+ // seat reservation not found / expired
596
+ if (reservedSeat === undefined) {
597
+ return false;
598
+ }
599
+
600
+ if (reservedSeat[3]) {
601
+ // reconnection
602
+ return (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId);
603
+
604
+ } else {
605
+ // seat reservation not consumed
606
+ return reservedSeat[2] === false;
607
+ }
608
+ }
609
+
610
+ public checkReconnectionToken(reconnectionToken: string) {
611
+ const sessionId = this._reconnections[reconnectionToken]?.[0];
612
+ const reservedSeat = this._reservedSeats[sessionId];
613
+
614
+ if (reservedSeat && reservedSeat[3]) {
615
+ return sessionId;
616
+
617
+ } else {
618
+ return undefined;
619
+ }
620
+ }
621
+
622
+ /**
623
+ * (Optional) Set a simulation interval that can change the state of the game.
624
+ * The simulation interval is your game loop.
625
+ *
626
+ * @default 16.6ms (60fps)
627
+ *
628
+ * @param onTickCallback - You can implement your physics or world updates here!
629
+ * This is a good place to update the room state.
630
+ * @param delay - Interval delay on executing `onTickCallback` in milliseconds.
631
+ */
632
+ public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
633
+ // clear previous interval in case called setSimulationInterval more than once
634
+ if (this._simulationInterval) { clearInterval(this._simulationInterval); }
635
+
636
+ if (onTickCallback) {
637
+ if (this.onUncaughtException !== undefined) {
638
+ onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval');
639
+ }
640
+
641
+ this._simulationInterval = setInterval(() => {
642
+ this.clock.tick();
643
+ onTickCallback(this.clock.deltaTime);
644
+ }, delay);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * @deprecated Use `.patchRate=` instead.
650
+ */
651
+ public setPatchRate(milliseconds: number | null): void {
652
+ this.patchRate = milliseconds;
653
+ }
654
+
655
+ /**
656
+ * @deprecated Use `.state =` instead.
657
+ */
658
+ public setState(newState: ExtractRoomState<T>) {
659
+ this.state = newState;
660
+ }
661
+
662
+ public setSerializer(serializer: Serializer<ExtractRoomState<T>>) {
663
+ this._serializer = serializer;
664
+ }
665
+
666
+ public async setMetadata(meta: Partial<ExtractRoomMetadata<T>>, persist: boolean = true) {
667
+ if (!this._listing.metadata) {
668
+ this._listing.metadata = meta as ExtractRoomMetadata<T>;
669
+
670
+ } else {
671
+ for (const field in meta) {
672
+ if (!meta.hasOwnProperty(field)) { continue; }
673
+ this._listing.metadata[field] = meta[field];
674
+ }
675
+
676
+ // `MongooseDriver` workaround: persit metadata mutations
677
+ if ('markModified' in this._listing) {
678
+ (this._listing as any).markModified('metadata');
679
+ }
680
+ }
681
+
682
+ if (persist && this._internalState === RoomInternalState.CREATED) {
683
+ await matchMaker.driver.persist(this._listing);
684
+
685
+ // emit metadata-change event to update lobby listing
686
+ this._events.emit('metadata-change');
687
+ }
688
+ }
689
+
690
+ public async setPrivate(bool: boolean = true, persist: boolean = true) {
691
+ if (this._listing.private === bool) return;
692
+
693
+ this._listing.private = bool;
694
+
695
+ if (persist && this._internalState === RoomInternalState.CREATED) {
696
+ await matchMaker.driver.persist(this._listing);
697
+ }
698
+
699
+ // emit visibility-change event to update lobby listing
700
+ this._events.emit('visibility-change', bool);
701
+ }
702
+
703
+ /**
704
+ * Update multiple matchmaking/listing properties at once with a single persist operation.
705
+ * This is the recommended way to update room listing properties.
706
+ *
707
+ * @param updates - Object containing the properties to update
708
+ *
709
+ * @example
710
+ * ```typescript
711
+ * // Update multiple properties at once
712
+ * await this.setMatchmaking({
713
+ * metadata: { difficulty: "hard", rating: 1500 },
714
+ * private: true,
715
+ * locked: true,
716
+ * maxClients: 10
717
+ * });
718
+ * ```
719
+ *
720
+ * @example
721
+ * ```typescript
722
+ * // Update only metadata
723
+ * await this.setMatchmaking({
724
+ * metadata: { status: "in_progress" }
725
+ * });
726
+ * ```
727
+ *
728
+ * @example
729
+ * ```typescript
730
+ * // Partial metadata update (merges with existing)
731
+ * await this.setMatchmaking({
732
+ * metadata: { ...this.metadata, round: this.metadata.round + 1 }
733
+ * });
734
+ * ```
735
+ */
736
+ public async setMatchmaking(updates: {
737
+ metadata?: ExtractRoomMetadata<T>;
738
+ private?: boolean;
739
+ locked?: boolean;
740
+ maxClients?: number;
741
+ unlisted?: boolean;
742
+ [key: string]: any;
743
+ }) {
744
+ for (const key in updates) {
745
+ if (!updates.hasOwnProperty(key)) { continue; }
746
+
747
+ switch (key) {
748
+ case 'metadata': {
749
+ this.setMetadata(updates.metadata, false);
750
+ break;
751
+ }
752
+
753
+ case 'private': {
754
+ this.setPrivate(updates.private, false);
755
+ break;
756
+ }
757
+
758
+ case 'locked': {
759
+ if (updates[key]) {
760
+ // @ts-ignore
761
+ this.lock.call(this, true);
762
+ this._lockedExplicitly = true;
763
+ } else {
764
+ // @ts-ignore
765
+ this.unlock.call(this, true);
766
+ this._lockedExplicitly = false;
767
+ }
768
+ break;
769
+ }
770
+
771
+ case 'maxClients': {
772
+ this.#_maxClients = updates.maxClients;
773
+ this._listing.maxClients = updates.maxClients;
774
+
775
+ const hasReachedMaxClients = this.hasReachedMaxClients();
776
+
777
+ // unlock room if maxClients has been increased
778
+ if (!this._lockedExplicitly && this.#_maxClientsReached && !hasReachedMaxClients) {
779
+ this.#_maxClientsReached = false;
780
+ this.#_locked = false;
781
+ this._listing.locked = false;
782
+ updates.locked = false;
783
+ }
784
+
785
+ // lock room if maxClients has been decreased
786
+ if (hasReachedMaxClients) {
787
+ this.#_maxClientsReached = true;
788
+ this.#_locked = true;
789
+ this._listing.locked = true;
790
+ updates.locked = true;
791
+ }
792
+
793
+ break;
794
+ }
795
+
796
+ case 'clients': {
797
+ console.warn("setMatchmaking() does not allow updating 'clients' property.");
798
+ break;
799
+ }
800
+
801
+ default: {
802
+ // Allow any other listing properties to be updated
803
+ this._listing[key] = updates[key];
804
+ break;
805
+ }
806
+ }
807
+ }
808
+
809
+ // Only persist if room is not CREATING
810
+ if (this._internalState === RoomInternalState.CREATED) {
811
+ await matchMaker.driver.update(this._listing, { $set: updates });
812
+
813
+ // emit metadata-change event to update lobby listing
814
+ this._events.emit('metadata-change');
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Lock the room. This prevents new clients from joining this room.
820
+ */
821
+ public async lock() {
822
+ // rooms locked internally aren't explicit locks.
823
+ this._lockedExplicitly = (arguments[0] === undefined);
824
+
825
+ // skip if already locked.
826
+ if (this.#_locked) { return; }
827
+
828
+ this.#_locked = true;
829
+
830
+ // Only persist if this is an explicit lock/unlock
831
+ if (this._lockedExplicitly) {
832
+ await matchMaker.driver.update(this._listing, {
833
+ $set: { locked: this.#_locked },
834
+ });
835
+ }
836
+
837
+ this._events.emit('lock');
838
+ }
839
+
840
+ /**
841
+ * Unlock the room. This allows new clients to join this room, if maxClients is not reached.
842
+ */
843
+ public async unlock() {
844
+ // only internal usage passes arguments to this function.
845
+ if (arguments[0] === undefined) {
846
+ this._lockedExplicitly = false;
847
+ }
848
+
849
+ // skip if already locked
850
+ if (!this.#_locked) { return; }
851
+
852
+ this.#_locked = false;
853
+
854
+ // Only persist if this is an explicit lock/unlock
855
+ if (arguments[0] === undefined) {
856
+ await matchMaker.driver.update(this._listing, {
857
+ $set: { locked: this.#_locked },
858
+ });
859
+ }
860
+
861
+ this._events.emit('unlock');
862
+ }
863
+
864
+ /**
865
+ * @deprecated Use `client.send(...)` instead.
866
+ */
867
+ public send(client: Client, type: string | number, message: any, options?: ISendOptions): void;
868
+ public send(client: Client, messageOrType: any, messageOrOptions?: any | ISendOptions, options?: ISendOptions): void {
869
+ logger.warn('DEPRECATION WARNING: use client.send(...) instead of this.send(client, ...)');
870
+ client.send(messageOrType, messageOrOptions, options);
871
+ }
872
+
873
+ /**
874
+ * Broadcast a message to all connected clients.
875
+ * @param type - The type of the message.
876
+ * @param message - The message to broadcast.
877
+ * @param options - The options for the broadcast.
878
+ *
879
+ * @example
880
+ * ```typescript
881
+ * this.broadcast('message', { message: 'Hello, world!' });
882
+ * ```
883
+ */
884
+ public broadcast<K extends keyof ExtractRoomClient<T>['~messages'] & string | number>(
885
+ type: K,
886
+ ...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
887
+ ) {
888
+ const [message, options] = args;
889
+ if (options && options.afterNextPatch) {
890
+ delete options.afterNextPatch;
891
+ this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
892
+ return;
893
+ }
894
+
895
+ this.broadcastMessageType(type, message, options);
896
+ }
897
+
898
+ /**
899
+ * Broadcast bytes (UInt8Arrays) to a particular room
900
+ */
901
+ public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
902
+ if (options && options.afterNextPatch) {
903
+ delete options.afterNextPatch;
904
+ this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
905
+ return;
906
+ }
907
+
908
+ this.broadcastMessageType(type as string, message, options);
909
+ }
910
+
911
+ /**
912
+ * Checks whether mutations have occurred in the state, and broadcast them to all connected clients.
913
+ */
914
+ public broadcastPatch() {
915
+ if (this.onBeforePatch) {
916
+ this.onBeforePatch(this.state);
917
+ }
918
+
919
+ if (!this._simulationInterval) {
920
+ this.clock.tick();
921
+ }
922
+
923
+ if (!this.state) {
924
+ return false;
925
+ }
926
+
927
+ const hasChanges = this._serializer.applyPatches(this.clients, this.state);
928
+
929
+ // broadcast messages enqueued for "after patch"
930
+ this._dequeueAfterPatchMessages();
931
+
932
+ return hasChanges;
933
+ }
934
+
935
+ /**
936
+ * Register a message handler for a specific message type.
937
+ * This method is used to handle messages sent by clients to the room.
938
+ * @param messageType - The type of the message.
939
+ * @param callback - The callback to call when the message is received.
940
+ * @returns A function to unbind the callback.
941
+ *
942
+ * @example
943
+ * ```typescript
944
+ * this.onMessage('message', (client, message) => {
945
+ * console.log(message);
946
+ * });
947
+ * ```
948
+ *
949
+ * @example
950
+ * ```typescript
951
+ * const unbind = this.onMessage('message', (client, message) => {
952
+ * console.log(message);
953
+ * });
954
+ *
955
+ * // Unbind the callback when no longer needed
956
+ * unbind();
957
+ * ```
958
+ */
959
+ public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
960
+ // public onMessage<T = any, C extends Client = TClient>(
961
+ messageType: '*',
962
+ callback: (client: C, type: string | number, message: T) => void
963
+ );
964
+ public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
965
+ // public onMessage<T = any, C extends Client = TClient>(
966
+ messageType: string | number,
967
+ callback: (client: C, message: T) => void,
968
+ );
969
+ public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
970
+ // public onMessage<T = any, C extends Client = TClient>(
971
+ messageType: string | number,
972
+ validationSchema: StandardSchemaV1<T>,
973
+ callback: (client: C, message: T) => void,
974
+ );
975
+ public onMessage<T = any>(
976
+ _messageType: '*' | string | number,
977
+ _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
978
+ _callback?: (...args: any[]) => void,
979
+ ) {
980
+ const messageType = _messageType.toString();
981
+
982
+ const validationSchema = (typeof _callback === 'function')
983
+ ? _validationSchema as StandardSchemaV1<T>
984
+ : undefined;
985
+
986
+ const callback = (validationSchema === undefined)
987
+ ? _validationSchema as (...args: any[]) => void
988
+ : _callback;
989
+
990
+ const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
991
+ ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
992
+ : callback);
993
+
994
+ if (validationSchema !== undefined) {
995
+ this.onMessageValidators[messageType] = validationSchema;
996
+ }
997
+
998
+ // returns a method to unbind the callback
999
+ return () => {
1000
+ removeListener();
1001
+ if (this.onMessageEvents.events[messageType].length === 0) {
1002
+ delete this.onMessageValidators[messageType];
1003
+ }
1004
+ };
1005
+ }
1006
+
1007
+ public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
1008
+ // public onMessageBytes<T = any, C extends Client = TClient>(
1009
+ messageType: string | number,
1010
+ callback: (client: C, message: T) => void,
1011
+ );
1012
+ public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
1013
+ // public onMessageBytes<T = any, C extends Client = TClient>(
1014
+ messageType: string | number,
1015
+ validationSchema: StandardSchemaV1<T>,
1016
+ callback: (client: C, message: T) => void,
1017
+ );
1018
+ public onMessageBytes<T = any>(
1019
+ _messageType: string | number,
1020
+ _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
1021
+ _callback?: (...args: any[]) => void,
1022
+ ) {
1023
+ const messageType = `_$b${_messageType}`;
1024
+
1025
+ const validationSchema = (typeof _callback === 'function')
1026
+ ? _validationSchema as StandardSchemaV1<T>
1027
+ : undefined;
1028
+
1029
+ const callback = (validationSchema === undefined)
1030
+ ? _validationSchema as (...args: any[]) => void
1031
+ : _callback;
1032
+
1033
+ if (validationSchema !== undefined) {
1034
+ return this.onMessage(messageType, validationSchema as any, callback as any);
1035
+ } else {
1036
+ return this.onMessage(messageType, callback as any);
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Disconnect all connected clients, and then dispose the room.
1042
+ *
1043
+ * @param closeCode WebSocket close code (default = 4000, which is a "consented leave")
1044
+ * @returns Promise<void>
1045
+ */
1046
+ public disconnect(closeCode: number = CloseCode.CONSENTED): Promise<any> {
1047
+ // skip if already disposing
1048
+ if (this._internalState === RoomInternalState.DISPOSING) {
1049
+ return Promise.resolve(`disconnect() ignored: room (${this.roomId}) is already disposing.`);
1050
+
1051
+ } else if (this._internalState === RoomInternalState.CREATING) {
1052
+ throw new Error("cannot disconnect during onCreate()");
1053
+ }
1054
+
1055
+ this._internalState = RoomInternalState.DISPOSING;
1056
+ matchMaker.driver.remove(this._listing.roomId);
1057
+
1058
+ this.#_autoDispose = true;
1059
+
1060
+ const delayedDisconnection = new Promise<void>((resolve) =>
1061
+ this._events.once('disconnect', () => resolve()));
1062
+
1063
+ // reject pending reconnections
1064
+ for (const [_, reconnection] of Object.values(this._reconnections)) {
1065
+ reconnection.reject(new Error("disconnecting"));
1066
+ }
1067
+
1068
+ let numClients = this.clients.length;
1069
+ if (numClients > 0) {
1070
+ // clients may have `async onLeave`, room will be disposed after they're fulfilled
1071
+ while (numClients--) {
1072
+ this.#_forciblyCloseClient(this.clients[numClients] as ExtractRoomClient<T> & ClientPrivate, closeCode);
1073
+ }
1074
+
1075
+ } else {
1076
+ // no clients connected, dispose immediately.
1077
+ this._events.emit('dispose');
1078
+ }
1079
+
1080
+ return delayedDisconnection;
1081
+ }
1082
+
1083
+ private async _onJoin(
1084
+ client: ExtractRoomClient<T> & ClientPrivate,
1085
+ authContext: AuthContext,
1086
+ connectionOptions?: { reconnectionToken?: string, skipHandshake?: boolean }
1087
+ ) {
1088
+ const sessionId = client.sessionId;
1089
+
1090
+ // generate unique private reconnection token
1091
+ client.reconnectionToken = generateId();
1092
+
1093
+ if (this._reservedSeatTimeouts[sessionId]) {
1094
+ clearTimeout(this._reservedSeatTimeouts[sessionId]);
1095
+ delete this._reservedSeatTimeouts[sessionId];
1096
+ }
1097
+
1098
+ // clear auto-dispose timeout.
1099
+ if (this._autoDisposeTimeout) {
1100
+ clearTimeout(this._autoDisposeTimeout);
1101
+ this._autoDisposeTimeout = undefined;
1102
+ }
1103
+
1104
+ // get seat reservation options and clear it
1105
+ const [joinOptions, authData, isConsumed, isWaitingReconnection] = this._reservedSeats[sessionId];
1106
+
1107
+ //
1108
+ // TODO: remove this check on 1.0.0
1109
+ // - the seat reservation is used to keep track of number of clients and their pending seats (see `hasReachedMaxClients`)
1110
+ // - when we fully migrate to static onAuth(), the seat reservation can be removed immediately here
1111
+ // - if async onAuth() is in use, the seat reservation is removed after onAuth() is fulfilled.
1112
+ // - mark reservation as "consumed"
1113
+ //
1114
+ if (isConsumed) {
1115
+ throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, "already consumed");
1116
+ }
1117
+ this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
1118
+ debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
1119
+
1120
+ // share "after next patch queue" reference with every client.
1121
+ client._afterNextPatchQueue = this._afterNextPatchQueue;
1122
+
1123
+ // add temporary callback to keep track of disconnections during `onJoin`.
1124
+ client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
1125
+ client.ref.once('close', client.ref['onleave']);
1126
+
1127
+ if (isWaitingReconnection) {
1128
+ const reconnectionToken = connectionOptions?.reconnectionToken;
1129
+ if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
1130
+ this.clients.push(client);
1131
+ //
1132
+ // await for reconnection:
1133
+ // (end user may customize the reconnection token at this step)
1134
+ //
1135
+ await this._reconnections[reconnectionToken]?.[1].resolve(client);
1136
+
1137
+ if (this.onReconnect) {
1138
+ await this.onReconnect(client);
1139
+ }
1140
+
1141
+ } else {
1142
+ const errorMessage = (process.env.NODE_ENV === 'production')
1143
+ ? "already consumed" // trick possible fraudsters...
1144
+ : "bad reconnection token" // ...or developers
1145
+ throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, errorMessage);
1146
+ }
1147
+
1148
+ } else {
1149
+ try {
1150
+ if (authData) {
1151
+ client.auth = authData;
1152
+
1153
+ } else if (this.onAuth !== Room.prototype.onAuth) {
1154
+ try {
1155
+ client.auth = await this.onAuth(client, joinOptions, authContext);
1156
+
1157
+ if (!client.auth) {
1158
+ throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');
1159
+ }
1160
+
1161
+ } catch (e) {
1162
+ // remove seat reservation
1163
+ delete this._reservedSeats[sessionId];
1164
+ await this.#_decrementClientCount();
1165
+ throw e;
1166
+ }
1167
+ }
1168
+
1169
+ //
1170
+ // On async onAuth, client may have been disconnected.
1171
+ //
1172
+ if (client.state === ClientState.LEAVING) {
1173
+ throw new ServerError(CloseCode.WITH_ERROR, 'already disconnected');
1174
+ }
1175
+
1176
+ this.clients.push(client);
1177
+
1178
+ //
1179
+ // Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
1180
+ // (https://github.com/colyseus/colyseus/issues/726)
1181
+ //
1182
+ Object.defineProperty(this._reservedSeats, sessionId, {
1183
+ value: this._reservedSeats[sessionId],
1184
+ enumerable: false,
1185
+ });
1186
+
1187
+ if (this.onJoin) {
1188
+ await this.onJoin(client, joinOptions);
1189
+ }
1190
+
1191
+ // @ts-ignore: client left during `onJoin`, call _onLeave immediately.
1192
+ if (client.state === ClientState.LEAVING) {
1193
+ throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, "early_leave");
1194
+
1195
+ } else {
1196
+ // remove seat reservation
1197
+ delete this._reservedSeats[sessionId];
1198
+
1199
+ // emit 'join' to room handler
1200
+ this._events.emit('join', client);
1201
+ }
1202
+
1203
+ } catch (e: any) {
1204
+ await this._onLeave(client, CloseCode.WITH_ERROR);
1205
+
1206
+ // remove seat reservation
1207
+ delete this._reservedSeats[sessionId];
1208
+
1209
+ // make sure an error code is provided.
1210
+ if (!e.code) {
1211
+ e.code = ErrorCode.APPLICATION_ERROR;
1212
+ }
1213
+
1214
+ throw e;
1215
+ }
1216
+ }
1217
+
1218
+ // state might already be ClientState.LEAVING here
1219
+ if (client.state === ClientState.JOINING) {
1220
+ client.ref.removeListener('close', client.ref['onleave']);
1221
+
1222
+ // only bind _onLeave after onJoin has been successful
1223
+ client.ref['onleave'] = this._onLeave.bind(this, client);
1224
+ client.ref.once('close', client.ref['onleave']);
1225
+
1226
+ // allow client to send messages after onJoin has succeeded.
1227
+ client.ref.on('message', this._onMessage.bind(this, client));
1228
+
1229
+ // confirm room id that matches the room name requested to join
1230
+ client.raw(getMessageBytes[Protocol.JOIN_ROOM](
1231
+ client.reconnectionToken,
1232
+ this._serializer.id,
1233
+ /**
1234
+ * if skipHandshake is true, we don't need to send the handshake
1235
+ * (in case client already has handshake data)
1236
+ */
1237
+ (connectionOptions?.skipHandshake)
1238
+ ? undefined
1239
+ : this._serializer.handshake && this._serializer.handshake(),
1240
+ ));
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * Allow the specified client to reconnect into the room. Must be used inside `onLeave()` method.
1246
+ * If seconds is provided, the reconnection is going to be cancelled after the provided amount of seconds.
1247
+ *
1248
+ * @param client - The client that is allowed to reconnect into the room.
1249
+ * @param seconds - The time in seconds that the client is allowed to reconnect into the room.
1250
+ *
1251
+ * @returns Deferred<Client> - The differed is a promise like type.
1252
+ * This type can forcibly reject the promise by calling `.reject()`.
1253
+ *
1254
+ * @example
1255
+ * ```typescript
1256
+ * onDrop(client: Client, code: CloseCode) {
1257
+ * // Allow the client to reconnect into the room with a 15 seconds timeout.
1258
+ * this.allowReconnection(client, 15);
1259
+ * }
1260
+ * ```
1261
+ */
1262
+ public allowReconnection(previousClient: Client, seconds: number | "manual"): Deferred<Client> {
1263
+ //
1264
+ // Return rejected promise if client has never fully JOINED.
1265
+ //
1266
+ // (having `_enqueuedMessages !== undefined` means that the client has never been at "ClientState.JOINED" state)
1267
+ //
1268
+ if ((previousClient as unknown as ClientPrivate)._enqueuedMessages !== undefined) {
1269
+ // @ts-ignore
1270
+ return Promise.reject(new Error("not joined"));
1271
+ }
1272
+
1273
+ if (seconds === undefined) { // TODO: remove this check
1274
+ console.warn("DEPRECATED: allowReconnection() requires a second argument. Using \"manual\" mode.");
1275
+ seconds = "manual";
1276
+ }
1277
+
1278
+ if (seconds === "manual") {
1279
+ seconds = Infinity;
1280
+ }
1281
+
1282
+ if (this._internalState === RoomInternalState.DISPOSING) {
1283
+ // @ts-ignore
1284
+ return Promise.reject(new Error("disposing"));
1285
+ }
1286
+
1287
+ const sessionId = previousClient.sessionId;
1288
+ const reconnectionToken = previousClient.reconnectionToken;
1289
+
1290
+ this._reserveSeat(sessionId, true, previousClient.auth, seconds, true);
1291
+
1292
+ // keep reconnection reference in case the user reconnects into this room.
1293
+ const reconnection = new Deferred<Client & ClientPrivate>();
1294
+ this._reconnections[reconnectionToken] = [sessionId, reconnection];
1295
+
1296
+ if (seconds !== Infinity) {
1297
+ // expire seat reservation after timeout
1298
+ this._reservedSeatTimeouts[sessionId] = setTimeout(() =>
1299
+ reconnection.reject(false), seconds * 1000);
1300
+ }
1301
+
1302
+ const cleanup = () => {
1303
+ delete this._reconnections[reconnectionToken];
1304
+ delete this._reservedSeats[sessionId];
1305
+ delete this._reservedSeatTimeouts[sessionId];
1306
+ };
1307
+
1308
+ reconnection.then((newClient) => {
1309
+ newClient.auth = previousClient.auth;
1310
+ newClient.userData = previousClient.userData;
1311
+ newClient.view = previousClient.view;
1312
+
1313
+ // for convenience: populate previous client reference with new client
1314
+ previousClient.state = ClientState.RECONNECTED;
1315
+ previousClient.ref = newClient.ref;
1316
+ previousClient.reconnectionToken = newClient.reconnectionToken;
1317
+ clearTimeout(this._reservedSeatTimeouts[sessionId]);
1318
+
1319
+ }, () => {
1320
+ this.resetAutoDisposeTimeout();
1321
+
1322
+ }).finally(() => {
1323
+ cleanup();
1324
+ });
1325
+
1326
+ return reconnection;
1327
+ }
1328
+
1329
+ private resetAutoDisposeTimeout(timeoutInSeconds: number = 1) {
1330
+ clearTimeout(this._autoDisposeTimeout);
1331
+
1332
+ if (!this.#_autoDispose) {
1333
+ return;
1334
+ }
1335
+
1336
+ this._autoDisposeTimeout = setTimeout(() => {
1337
+ this._autoDisposeTimeout = undefined;
1338
+ this.#_disposeIfEmpty();
1339
+ }, timeoutInSeconds * 1000);
1340
+ }
1341
+
1342
+ private broadcastMessageType(type: number | string, message?: any | Uint8Array, options: IBroadcastOptions = {}) {
1343
+ debugMessage("broadcast: %O (roomId: %s)", message, this.roomId);
1344
+
1345
+ const encodedMessage = (message instanceof Uint8Array)
1346
+ ? getMessageBytes.raw(Protocol.ROOM_DATA_BYTES, type, undefined, message)
1347
+ : getMessageBytes.raw(Protocol.ROOM_DATA, type, message)
1348
+
1349
+ const except = (typeof (options.except) !== "undefined")
1350
+ ? Array.isArray(options.except)
1351
+ ? options.except
1352
+ : [options.except]
1353
+ : undefined;
1354
+
1355
+ let numClients = this.clients.length;
1356
+ while (numClients--) {
1357
+ const client = this.clients[numClients];
1358
+
1359
+ if (!except || !except.includes(client)) {
1360
+ client.enqueueRaw(encodedMessage);
1361
+ }
1362
+ }
1363
+ }
1364
+
1365
+ private sendFullState(client: Client): void {
1366
+ client.raw(this._serializer.getFullState(client));
1367
+ }
1368
+
1369
+ private _dequeueAfterPatchMessages() {
1370
+ const length = this._afterNextPatchQueue.length;
1371
+
1372
+ if (length > 0) {
1373
+ for (let i = 0; i < length; i++) {
1374
+ const [target, args] = this._afterNextPatchQueue[i];
1375
+
1376
+ if (target === "broadcast") {
1377
+ this.broadcast.apply(this, args as any);
1378
+
1379
+ } else {
1380
+ (target as Client).raw.apply(target, args as any);
1381
+ }
1382
+ }
1383
+
1384
+ // new messages may have been added in the meantime,
1385
+ // let's splice the ones that have been processed
1386
+ this._afterNextPatchQueue.splice(0, length);
1387
+ }
1388
+ }
1389
+
1390
+ private async _reserveSeat(
1391
+ sessionId: string,
1392
+ joinOptions: any = true,
1393
+ authData: any = undefined,
1394
+ seconds: number = this.seatReservationTimeout,
1395
+ allowReconnection: boolean = false,
1396
+ devModeReconnectionToken?: string,
1397
+ ) {
1398
+ if (!allowReconnection && this.hasReachedMaxClients()) {
1399
+ return false;
1400
+ }
1401
+
1402
+ debugMatchMaking(
1403
+ 'reserving seat. sessionId: \'%s\', roomId: \'%s\', processId: \'%s\'',
1404
+ sessionId, this.roomId, matchMaker.processId,
1405
+ );
1406
+
1407
+ this._reservedSeats[sessionId] = [joinOptions, authData, false, allowReconnection];
1408
+
1409
+ if (!allowReconnection) {
1410
+ await this.#_incrementClientCount();
1411
+
1412
+ this._reservedSeatTimeouts[sessionId] = setTimeout(async () => {
1413
+ delete this._reservedSeats[sessionId];
1414
+ delete this._reservedSeatTimeouts[sessionId];
1415
+ await this.#_decrementClientCount();
1416
+ }, seconds * 1000);
1417
+
1418
+ this.resetAutoDisposeTimeout(seconds);
1419
+ }
1420
+
1421
+ //
1422
+ // TODO: isDevMode workaround to allow players to reconnect on devMode
1423
+ //
1424
+ if (devModeReconnectionToken) {
1425
+ // Set up reconnection token mapping
1426
+ const reconnection = new Deferred<Client & ClientPrivate>();
1427
+ this._reconnections[devModeReconnectionToken] = [sessionId, reconnection];
1428
+ }
1429
+
1430
+ return true;
1431
+ }
1432
+
1433
+ private async _reserveMultipleSeats(
1434
+ multipleSessionIds: string[],
1435
+ multipleJoinOptions: any = true,
1436
+ multipleAuthData: any = undefined,
1437
+ seconds: number = this.seatReservationTimeout,
1438
+ ) {
1439
+ let promises: Promise<boolean>[] = [];
1440
+
1441
+ for (let i = 0; i < multipleSessionIds.length; i++) {
1442
+ promises.push(this._reserveSeat(multipleSessionIds[i], multipleJoinOptions[i], multipleAuthData[i], seconds));
1443
+ }
1444
+
1445
+ return await Promise.all(promises);
1446
+ }
1447
+
1448
+ #_disposeIfEmpty() {
1449
+ const willDispose = (
1450
+ this.#_onLeaveConcurrent === 0 && // no "onLeave" calls in progress
1451
+ this.#_autoDispose &&
1452
+ this._autoDisposeTimeout === undefined &&
1453
+ this.clients.length === 0 &&
1454
+ Object.keys(this._reservedSeats).length === 0
1455
+ );
1456
+
1457
+ if (willDispose) {
1458
+ this._events.emit('dispose');
1459
+ }
1460
+
1461
+ return willDispose;
1462
+ }
1463
+
1464
+ async #_dispose(): Promise<any> {
1465
+ this._internalState = RoomInternalState.DISPOSING;
1466
+
1467
+ // If the room is still CREATING, the roomId is not yet set.
1468
+ if (this._listing?.roomId !== undefined) {
1469
+ await matchMaker.driver.remove(this._listing.roomId);
1470
+ }
1471
+
1472
+ let userReturnData;
1473
+ if (this.onDispose) {
1474
+ userReturnData = this.onDispose();
1475
+ }
1476
+
1477
+ if (this.#_patchInterval) {
1478
+ clearInterval(this.#_patchInterval);
1479
+ this.#_patchInterval = undefined;
1480
+ }
1481
+
1482
+ if (this._simulationInterval) {
1483
+ clearInterval(this._simulationInterval);
1484
+ this._simulationInterval = undefined;
1485
+ }
1486
+
1487
+ if (this._autoDisposeTimeout) {
1488
+ clearInterval(this._autoDisposeTimeout);
1489
+ this._autoDisposeTimeout = undefined;
1490
+ }
1491
+
1492
+ // clear all timeouts/intervals + force to stop ticking
1493
+ this.clock.clear();
1494
+ this.clock.stop();
1495
+
1496
+ return await (userReturnData || Promise.resolve());
1497
+ }
1498
+
1499
+ private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
1500
+ // skip if client is on LEAVING state.
1501
+ if (client.state === ClientState.LEAVING) { return; }
1502
+
1503
+ if (!buffer) {
1504
+ debugAndPrintError(`${this.roomName} (roomId: ${this.roomId}), couldn't decode message: ${buffer}`);
1505
+ return;
1506
+ }
1507
+
1508
+ // reset message count every second
1509
+ if (this.clock.currentTime - client._lastMessageTime >= 1000) {
1510
+ client._numMessagesLastSecond = 0;
1511
+ client._lastMessageTime = this.clock.currentTime;
1512
+ } else if (++client._numMessagesLastSecond > this.maxMessagesPerSecond) {
1513
+ // drop client if it sends more messages than the maximum allowed per second
1514
+ return this.#_forciblyCloseClient(client, CloseCode.WITH_ERROR);
1515
+ }
1516
+
1517
+ const it: Iterator = { offset: 1 };
1518
+ const code = buffer[0];
1519
+
1520
+ if (code === Protocol.ROOM_DATA) {
1521
+ const messageType = (decode.stringCheck(buffer, it))
1522
+ ? decode.string(buffer, it)
1523
+ : decode.number(buffer, it);
1524
+
1525
+ let message;
1526
+ try {
1527
+ message = (buffer.byteLength > it.offset)
1528
+ ? unpack(buffer.subarray(it.offset, buffer.byteLength))
1529
+ : undefined;
1530
+ debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
1531
+
1532
+ // custom message validation
1533
+ if (this.onMessageValidators[messageType] !== undefined) {
1534
+ message = standardValidate(this.onMessageValidators[messageType], message);
1535
+ }
1536
+
1537
+ } catch (e: any) {
1538
+ debugAndPrintError(e);
1539
+ client.leave(CloseCode.WITH_ERROR);
1540
+ return;
1541
+ }
1542
+
1543
+ if (this.onMessageEvents.events[messageType]) {
1544
+ this.onMessageEvents.emit(messageType as string, client, message);
1545
+
1546
+ } else if (this.onMessageEvents.events['*']) {
1547
+ this.onMessageEvents.emit('*', client, messageType, message);
1548
+
1549
+ } else {
1550
+ this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1551
+ }
1552
+
1553
+ } else if (code === Protocol.ROOM_DATA_BYTES) {
1554
+ const messageType = (decode.stringCheck(buffer, it))
1555
+ ? decode.string(buffer, it)
1556
+ : decode.number(buffer, it);
1557
+
1558
+ let message: any = buffer.subarray(it.offset, buffer.byteLength);
1559
+ debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
1560
+
1561
+ const bytesMessageType = `_$b${messageType}`;
1562
+
1563
+ // custom message validation
1564
+ try {
1565
+ if (this.onMessageValidators[bytesMessageType] !== undefined) {
1566
+ message = standardValidate(this.onMessageValidators[bytesMessageType], message);
1567
+ }
1568
+ } catch (e: any) {
1569
+ debugAndPrintError(e);
1570
+ client.leave(CloseCode.WITH_ERROR);
1571
+ return;
1572
+ }
1573
+
1574
+ if (this.onMessageEvents.events[bytesMessageType]) {
1575
+ this.onMessageEvents.emit(bytesMessageType, client, message);
1576
+
1577
+ } else if (this.onMessageEvents.events['*']) {
1578
+ this.onMessageEvents.emit('*', client, messageType, message);
1579
+
1580
+ } else {
1581
+ this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1582
+ }
1583
+
1584
+ } else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
1585
+ // join room has been acknowledged by the client
1586
+ client.state = ClientState.JOINED;
1587
+ client._joinedAt = this.clock.elapsedTime;
1588
+
1589
+ // send current state when new client joins the room
1590
+ if (this.state) {
1591
+ this.sendFullState(client);
1592
+ }
1593
+
1594
+ // dequeue messages sent before client has joined effectively (on user-defined `onJoin`)
1595
+ if (client._enqueuedMessages.length > 0) {
1596
+ client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
1597
+ }
1598
+ delete client._enqueuedMessages;
1599
+
1600
+ } else if (code === Protocol.PING) {
1601
+ client.raw(getMessageBytes[Protocol.PING]());
1602
+
1603
+ } else if (code === Protocol.LEAVE_ROOM) {
1604
+ this.#_forciblyCloseClient(client, CloseCode.CONSENTED);
1605
+ }
1606
+ }
1607
+
1608
+ #_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
1609
+ // stop receiving messages from this client
1610
+ client.ref.removeAllListeners('message');
1611
+
1612
+ // prevent "onLeave" from being called twice if player asks to leave
1613
+ client.ref.removeListener('close', client.ref['onleave']);
1614
+
1615
+ // only effectively close connection when "onLeave" is fulfilled
1616
+ this._onLeave(client, closeCode).then(() => client.leave(closeCode));
1617
+ }
1618
+
1619
+ private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
1620
+ // call 'onLeave' method only if the client has been successfully accepted.
1621
+ client.state = ClientState.LEAVING;
1622
+
1623
+ if (!this.clients.delete(client)) {
1624
+ // skip if client already left the room
1625
+ return;
1626
+ }
1627
+
1628
+ const method = (code === CloseCode.CONSENTED)
1629
+ ? this.onLeave
1630
+ : (this.onDrop || this.onLeave);
1631
+
1632
+ if (method) {
1633
+ debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
1634
+
1635
+ try {
1636
+ this.#_onLeaveConcurrent++;
1637
+ await method.call(this, client, code);
1638
+
1639
+ } catch (e: any) {
1640
+ debugAndPrintError(`${method.name} error: ${(e && e.message || e || 'promise rejected')} (roomId: ${this.roomId})`);
1641
+
1642
+ } finally {
1643
+ this.#_onLeaveConcurrent--;
1644
+ }
1645
+ }
1646
+
1647
+ // check for manual "reconnection" flow
1648
+ if (this._reconnections[client.reconnectionToken]) {
1649
+ this._reconnections[client.reconnectionToken][1].catch(async () => {
1650
+ await this.#_onAfterLeave(client, code, method === this.onDrop);
1651
+ });
1652
+
1653
+ // @ts-ignore (client.state may be modified at onLeave())
1654
+ } else if (client.state !== ClientState.RECONNECTED) {
1655
+ await this.#_onAfterLeave(client, code, method === this.onDrop);
1656
+ }
1657
+ }
1658
+
1659
+ async #_onAfterLeave(client: ExtractRoomClient<T>, code?: number, isDrop: boolean = false) {
1660
+ if (isDrop && this.onLeave) {
1661
+ await this.onLeave(client, code);
1662
+ }
1663
+
1664
+ // try to dispose immediately if client reconnection isn't set up.
1665
+ const willDispose = await this.#_decrementClientCount();
1666
+
1667
+ // trigger 'leave' only if seat reservation has been fully consumed
1668
+ if (this._reservedSeats[client.sessionId] === undefined) {
1669
+ this._events.emit('leave', client, willDispose);
1670
+ }
1671
+
1672
+ }
1673
+
1674
+ async #_incrementClientCount() {
1675
+ // lock automatically when maxClients is reached
1676
+ if (!this.#_locked && this.hasReachedMaxClients()) {
1677
+ this.#_maxClientsReached = true;
1678
+
1679
+ // @ts-ignore
1680
+ this.lock.call(this, true);
1681
+ }
1682
+
1683
+ await matchMaker.driver.update(this._listing, {
1684
+ $inc: { clients: 1 },
1685
+ $set: { locked: this.#_locked },
1686
+ });
1687
+ }
1688
+
1689
+ async #_decrementClientCount() {
1690
+ const willDispose = this.#_disposeIfEmpty();
1691
+
1692
+ if (this._internalState === RoomInternalState.DISPOSING) {
1693
+ return true;
1694
+ }
1695
+
1696
+ // unlock if room is available for new connections
1697
+ if (!willDispose) {
1698
+ if (this.#_maxClientsReached && !this._lockedExplicitly) {
1699
+ this.#_maxClientsReached = false;
1700
+
1701
+ // @ts-ignore
1702
+ this.unlock.call(this, true);
1703
+ }
1704
+
1705
+ // update room listing cache
1706
+ await matchMaker.driver.update(this._listing, {
1707
+ $inc: { clients: -1 },
1708
+ $set: { locked: this.#_locked },
1709
+ });
1710
+ }
1711
+
1712
+ return willDispose;
1713
+ }
1714
+
1715
+ #registerUncaughtExceptionHandlers() {
1716
+ const onUncaughtException = this.onUncaughtException.bind(this);
1717
+ const originalSetTimeout = this.clock.setTimeout;
1718
+ this.clock.setTimeout = (cb, timeout, ...args) => {
1719
+ return originalSetTimeout.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, 'setTimeout'), timeout, ...args);
1720
+ };
1721
+
1722
+ const originalSetInterval = this.clock.setInterval;
1723
+ this.clock.setInterval = (cb, timeout, ...args) => {
1724
+ return originalSetInterval.call(this.clock, wrapTryCatch(cb, onUncaughtException, TimedEventException, 'setInterval'), timeout, ...args);
1725
+ };
1726
+
1727
+ if (this.onCreate !== undefined) {
1728
+ this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, 'onCreate', true);
1729
+ }
1730
+
1731
+ if (this.onAuth !== undefined) {
1732
+ this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, 'onAuth', true);
1733
+ }
1734
+
1735
+ if (this.onJoin !== undefined) {
1736
+ this.onJoin = wrapTryCatch(this.onJoin.bind(this), onUncaughtException, OnJoinException, 'onJoin', true);
1737
+ }
1738
+
1739
+ if (this.onLeave !== undefined) {
1740
+ this.onLeave = wrapTryCatch(this.onLeave.bind(this), onUncaughtException, OnLeaveException, 'onLeave', true);
1741
+ }
1742
+
1743
+ if (this.onDispose !== undefined) {
1744
+ this.onDispose = wrapTryCatch(this.onDispose.bind(this), onUncaughtException, OnDisposeException, 'onDispose');
1745
+ }
1746
+ }
1747
+
1748
+ }
1749
+
1750
+ /**
1751
+ * (WIP) Alternative, method-based room definition.
1752
+ * We should be able to define
1753
+ */
1754
+
1755
+ type RoomLifecycleMethods =
1756
+ | 'messages'
1757
+ | 'onCreate'
1758
+ | 'onJoin'
1759
+ | 'onLeave'
1760
+ | 'onDispose'
1761
+ | 'onCacheRoom'
1762
+ | 'onRestoreRoom'
1763
+ | 'onDrop'
1764
+ | 'onReconnect'
1765
+ | 'onUncaughtException'
1766
+ | 'onAuth'
1767
+ | 'onBeforeShutdown'
1768
+ | 'onBeforePatch';
1769
+
1770
+ type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
1771
+ Partial<Pick<Room<T>, RoomLifecycleMethods>> &
1772
+ { state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
1773
+ ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
1774
+ ThisType<Room<T>>
1775
+ ;
1776
+
1777
+ export function room<T>(options: DefineRoomOptions<T>) {
1778
+ class _ extends Room<T> {
1779
+ messages = options.messages;
1780
+
1781
+ constructor() {
1782
+ super();
1783
+ if (options.state && typeof options.state === 'function') {
1784
+ this.state = options.state();
1785
+ }
1786
+ }
1787
+ }
1788
+
1789
+ // Copy all methods to the prototype
1790
+ for (const key in options) {
1791
+ if (typeof options[key] === 'function') {
1792
+ _.prototype[key] = options[key];
1793
+ }
1794
+ }
1795
+
1796
+ return _ as typeof Room<T>;
1797
+ }