@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
@@ -0,0 +1,1119 @@
1
+ import { EventEmitter } from 'events';
2
+ import { CloseCode, ErrorCode } from './Protocol.ts';
3
+
4
+ import { requestFromIPC, subscribeIPC, subscribeWithTimeout } from './IPC.ts';
5
+
6
+ import { type Type, Deferred, generateId, merge, retry, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME, REMOTE_ROOM_SHORT_TIMEOUT, type MethodName, type ExtractMethodOrPropertyType } from './utils/Utils.ts';
7
+ import { isDevMode, cacheRoomHistory, getPreviousProcessId, getRoomRestoreListKey, reloadFromCache } from './utils/DevMode.ts';
8
+
9
+ import { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';
10
+ import { type OnCreateOptions, Room, RoomInternalState } from './Room.ts';
11
+
12
+ import { LocalPresence } from './presence/LocalPresence.ts';
13
+ import { createScopedPresence, type Presence } from './presence/Presence.ts';
14
+
15
+ import { debugAndPrintError, debugMatchMaking } from './Debug.ts';
16
+ import { SeatReservationError } from './errors/SeatReservationError.ts';
17
+ import { ServerError } from './errors/ServerError.ts';
18
+
19
+ import { type IRoomCache, type MatchMakerDriver, type SortOptions, LocalDriver } from './matchmaker/LocalDriver/LocalDriver.ts';
20
+ import controller from './matchmaker/controller.ts';
21
+ import * as stats from './Stats.ts';
22
+
23
+ import { logger } from './Logger.ts';
24
+ import type { AuthContext, Client } from './Transport.ts';
25
+ import { getLockId, initializeRoomCache, type ExtractMetadata } from './matchmaker/driver.ts';
26
+
27
+ export { controller, stats, type MatchMakerDriver };
28
+
29
+ export type ClientOptions = any;
30
+ export type SelectProcessIdCallback = (roomName: string, clientOptions: ClientOptions) => Promise<string>;
31
+
32
+ export interface ISeatReservation {
33
+ name: string;
34
+ sessionId: string;
35
+ roomId: string;
36
+ publicAddress?: string;
37
+ processId?: string;
38
+ reconnectionToken?: string;
39
+ devMode?: boolean;
40
+ }
41
+
42
+ const handlers: {[id: string]: RegisteredHandler} = {};
43
+ const rooms: {[roomId: string]: Room} = {};
44
+ const events = new EventEmitter();
45
+
46
+ export let publicAddress: string;
47
+ export let processId: string;
48
+ export let presence: Presence;
49
+ export let driver: MatchMakerDriver;
50
+
51
+ /**
52
+ * Function to select the processId to create the room on.
53
+ * By default, returns the process with least amount of rooms created.
54
+ * @returns The processId to create the room on.
55
+ */
56
+ export let selectProcessIdToCreateRoom: SelectProcessIdCallback = async function () {
57
+ return (await stats.fetchAll())
58
+ .sort((p1, p2) => p1.roomCount > p2.roomCount ? 1 : -1)[0]?.processId || processId;
59
+ };
60
+
61
+ /**
62
+ * Whether health checks are enabled or not. (default: true)
63
+ *
64
+ * Health checks are automatically performed on theses scenarios:
65
+ * - At startup, to check for leftover/invalid processId's
66
+ * - When a remote room creation request times out
67
+ * - When a remote seat reservation request times out
68
+ */
69
+ let enableHealthChecks: boolean = true;
70
+ export function setHealthChecksEnabled(value: boolean) {
71
+ enableHealthChecks = value;
72
+ }
73
+
74
+ export let onReady: Deferred = new Deferred(); // onReady needs to be immediately available to @colyseus/auth integration.
75
+
76
+ export const MatchMakerState = {
77
+ INITIALIZING: 0,
78
+ READY: 1,
79
+ SHUTTING_DOWN: 2,
80
+ } as const;
81
+ export type MatchMakerState = (typeof MatchMakerState)[keyof typeof MatchMakerState];
82
+
83
+ /**
84
+ * Internal MatchMaker state
85
+ */
86
+ export let state: MatchMakerState;
87
+
88
+ /**
89
+ * @private
90
+ */
91
+ export async function setup(
92
+ _presence?: Presence,
93
+ _driver?: MatchMakerDriver,
94
+ _publicAddress?: string,
95
+ _selectProcessIdToCreateRoom?: SelectProcessIdCallback,
96
+ ) {
97
+ if (onReady === undefined) {
98
+ //
99
+ // for testing purposes only: onReady is turned into undefined on shutdown
100
+ // (needs refactoring.)
101
+ //
102
+ onReady = new Deferred();
103
+ }
104
+
105
+ state = MatchMakerState.INITIALIZING;
106
+
107
+ presence = _presence || new LocalPresence();
108
+
109
+ driver = _driver || new LocalDriver();
110
+ publicAddress = _publicAddress;
111
+
112
+ stats.reset(false);
113
+
114
+ // devMode: try to retrieve previous processId
115
+ if (isDevMode) { processId = await getPreviousProcessId(); }
116
+
117
+ // ensure processId is set
118
+ if (!processId) { processId = generateId(); }
119
+
120
+ /**
121
+ * Override default `selectProcessIdToCreateRoom` function.
122
+ */
123
+ if (_selectProcessIdToCreateRoom) {
124
+ selectProcessIdToCreateRoom = _selectProcessIdToCreateRoom;
125
+ }
126
+
127
+ // boot driver if necessary (e.g. RedisDriver/PostgresDriver)
128
+ if (driver.boot) {
129
+ await driver.boot();
130
+ }
131
+
132
+ onReady.resolve();
133
+ }
134
+
135
+ /**
136
+ * - Accept receiving remote room creation requests
137
+ * - Check for leftover/invalid processId's on startup
138
+ * @private
139
+ */
140
+ export async function accept() {
141
+ await onReady; // make sure "processId" is available
142
+
143
+ /**
144
+ * Process-level subscription
145
+ * - handle remote process healthcheck
146
+ * - handle remote room creation
147
+ */
148
+ await subscribeIPC(presence, getProcessChannel(), (method: string, args: any) => {
149
+ if (method === 'healthcheck') {
150
+ // health check for this processId
151
+ return true;
152
+
153
+ } else {
154
+ // handle room creation
155
+ return handleCreateRoom.apply(undefined, args);
156
+ }
157
+ });
158
+
159
+ /**
160
+ * Check for leftover/invalid processId's on startup
161
+ */
162
+ if (enableHealthChecks) {
163
+ await healthCheckAllProcesses();
164
+
165
+ /*
166
+ * persist processId every 1 minute
167
+ *
168
+ * FIXME: this is a workaround in case this `processId` gets excluded
169
+ * (`stats.excludeProcess()`) by mistake due to health-check failure
170
+ */
171
+ stats.setAutoPersistInterval();
172
+ }
173
+
174
+ state = MatchMakerState.READY;
175
+
176
+ await stats.persist();
177
+
178
+ if (isDevMode) {
179
+ await reloadFromCache();
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Join or create into a room and return seat reservation
185
+ */
186
+ export async function joinOrCreate(roomName: string, clientOptions: ClientOptions = {}, authContext?: AuthContext) {
187
+ return await retry<Promise<ISeatReservation>>(async () => {
188
+ const authData = await callOnAuth(roomName, clientOptions, authContext);
189
+ let room: IRoomCache = await findOneRoomAvailable(roomName, clientOptions);
190
+
191
+ if (!room) {
192
+ const handler = getHandler(roomName);
193
+ const filterOptions = handler.getFilterOptions(clientOptions);
194
+ const concurrencyKey = getLockId(filterOptions);
195
+
196
+ //
197
+ // Prevent multiple rooms of same filter from being created concurrently
198
+ //
199
+ await concurrentJoinOrCreateRoomLock(handler, concurrencyKey, async (roomId?: string) => {
200
+ if (roomId) {
201
+ room = await driver.findOne({ roomId })
202
+ }
203
+
204
+ // If the room is not found or is already locked, try to find a new one
205
+ if (!room || room.locked) {
206
+ room = await findOneRoomAvailable(roomName, clientOptions);
207
+ }
208
+
209
+ if (!room) {
210
+ //
211
+ // TODO [?]
212
+ // should we expose the "creator" auth data of the room during `onCreate()`?
213
+ // it would be useful, though it could be accessed via `onJoin()` for now.
214
+ //
215
+ room = await createRoom(roomName, clientOptions);
216
+
217
+ // Notify waiting concurrent requests about the new room
218
+ presence.publish(`concurrent:${handler.name}:${concurrencyKey}`, room.roomId);
219
+ }
220
+
221
+ return room;
222
+ });
223
+ }
224
+
225
+ return await reserveSeatFor(room, clientOptions, authData);
226
+ }, 5, [SeatReservationError]);
227
+ }
228
+
229
+ /**
230
+ * Create a room and return seat reservation
231
+ */
232
+ export async function create(roomName: string, clientOptions: ClientOptions = {}, authContext?: AuthContext) {
233
+ const authData = await callOnAuth(roomName, clientOptions, authContext);
234
+ const room = await createRoom(roomName, clientOptions);
235
+ return reserveSeatFor(room, clientOptions, authData);
236
+ }
237
+
238
+ /**
239
+ * Join a room and return seat reservation
240
+ */
241
+ export async function join(roomName: string, clientOptions: ClientOptions = {}, authContext?: AuthContext) {
242
+ return await retry<Promise<ISeatReservation>>(async () => {
243
+ const authData = await callOnAuth(roomName, clientOptions, authContext);
244
+ const room = await findOneRoomAvailable(roomName, clientOptions);
245
+
246
+ if (!room) {
247
+ throw new ServerError(ErrorCode.MATCHMAKE_INVALID_CRITERIA, `no rooms found with provided criteria`);
248
+ }
249
+
250
+ return reserveSeatFor(room, clientOptions, authData);
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Join a room by id and return seat reservation
256
+ */
257
+ export async function reconnect(roomId: string, clientOptions: ClientOptions = {}) {
258
+ const room = await driver.findOne({ roomId });
259
+ if (!room) {
260
+ // TODO: support a "logLevel" out of the box?
261
+ if (process.env.NODE_ENV !== 'production') {
262
+ logger.info(`❌ room "${roomId}" has been disposed. Did you miss .allowReconnection()?\n👉 https://docs.colyseus.io/server/room#allow-reconnection`);
263
+ }
264
+
265
+ throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" has been disposed.`);
266
+ }
267
+
268
+ // check for reconnection
269
+ const reconnectionToken = clientOptions.reconnectionToken;
270
+ if (!reconnectionToken) { throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, `'reconnectionToken' must be provided for reconnection.`); }
271
+
272
+
273
+ // respond to re-connection!
274
+ const sessionId = await remoteRoomCall(room.roomId, 'checkReconnectionToken', [reconnectionToken]);
275
+ if (sessionId) {
276
+ return buildSeatReservation(room, sessionId);
277
+
278
+ } else {
279
+ // TODO: support a "logLevel" out of the box?
280
+ if (process.env.NODE_ENV !== 'production') {
281
+ logger.info(`❌ reconnection token invalid or expired. Did you miss .allowReconnection()?\n👉 https://docs.colyseus.io/server/room#allow-reconnection`);
282
+ }
283
+ throw new ServerError(ErrorCode.MATCHMAKE_EXPIRED, `reconnection token invalid or expired.`);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Join a room by id and return client seat reservation. An exception is thrown if a room is not found for roomId.
289
+ *
290
+ * @param roomId - The Id of the specific room instance.
291
+ * @param clientOptions - Options for the client seat reservation (for `onJoin`/`onAuth`)
292
+ * @param authContext - Optional authentication token
293
+ *
294
+ * @returns Promise<SeatReservation> - A promise which contains `sessionId` and `IRoomCache`.
295
+ */
296
+ export async function joinById(roomId: string, clientOptions: ClientOptions = {}, authContext?: AuthContext) {
297
+ const room = await driver.findOne({ roomId });
298
+
299
+ if (!room) {
300
+ throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" not found`);
301
+
302
+ } else if (room.locked) {
303
+ throw new ServerError(ErrorCode.MATCHMAKE_INVALID_ROOM_ID, `room "${roomId}" is locked`);
304
+ }
305
+
306
+ const authData = await callOnAuth(room.name, clientOptions, authContext);
307
+
308
+ return reserveSeatFor(room, clientOptions, authData);
309
+ }
310
+
311
+ /**
312
+ * Perform a query for all cached rooms
313
+ */
314
+ export async function query<T extends Room = any>(
315
+ conditions: Partial<IRoomCache & ExtractMetadata<T>> = {},
316
+ sortOptions?: SortOptions,
317
+ ) {
318
+ return await driver.query<T>(conditions, sortOptions);
319
+ }
320
+
321
+ /**
322
+ * Find for a public and unlocked room available.
323
+ *
324
+ * @param roomName - The Id of the specific room.
325
+ * @param filterOptions - Filter options.
326
+ * @param sortOptions - Sorting options.
327
+ *
328
+ * @returns Promise<IRoomCache> - A promise contaning an object which includes room metadata and configurations.
329
+ */
330
+ export async function findOneRoomAvailable(
331
+ roomName: string,
332
+ filterOptions: ClientOptions,
333
+ additionalSortOptions?: SortOptions,
334
+ ) {
335
+ const handler = getHandler(roomName);
336
+ const sortOptions = Object.assign({}, handler.sortOptions ?? {});
337
+
338
+ if (additionalSortOptions) {
339
+ Object.assign(sortOptions, additionalSortOptions);
340
+ }
341
+
342
+ return await driver.findOne({
343
+ locked: false,
344
+ name: roomName,
345
+ private: false,
346
+ ...handler.getFilterOptions(filterOptions),
347
+ }, sortOptions);
348
+ }
349
+
350
+ /**
351
+ * Call a method or return a property on a remote room.
352
+ *
353
+ * @param roomId - The Id of the specific room instance.
354
+ * @param method - Method or attribute to call or retrive.
355
+ * @param args - Array of arguments for the method
356
+ *
357
+ * @returns Promise<any> - Returned value from the called or retrieved method/attribute.
358
+ */
359
+ export async function remoteRoomCall<TRoom = Room>(
360
+ roomId: string,
361
+ method: keyof TRoom,
362
+ args?: any[],
363
+ rejectionTimeout = REMOTE_ROOM_SHORT_TIMEOUT,
364
+ ): Promise<ExtractMethodOrPropertyType<TRoom, typeof method>> {
365
+ const room = rooms[roomId] as TRoom;
366
+
367
+ if (!room) {
368
+ try {
369
+ return await requestFromIPC(presence, getRoomChannel(roomId), method as string, args, rejectionTimeout);
370
+
371
+ } catch (e: any) {
372
+
373
+ //
374
+ // the room cache from an unavailable process might've been used here.
375
+ // perform a health-check on the process before proceeding.
376
+ // (this is a broken state when a process wasn't gracefully shut down)
377
+ //
378
+ if (method === '_reserveSeat' && e.message === "ipc_timeout") {
379
+ throw e;
380
+ }
381
+
382
+ // TODO: for 1.0, consider always throwing previous error directly.
383
+
384
+ const request = `${String(method)}${args && ' with args ' + JSON.stringify(args) || ''}`;
385
+ throw new ServerError(
386
+ ErrorCode.MATCHMAKE_UNHANDLED,
387
+ `remote room (${roomId}) timed out, requesting "${request}". (${rejectionTimeout}ms exceeded)`,
388
+ );
389
+ }
390
+
391
+ } else {
392
+ return (!args && typeof (room[method]) !== 'function')
393
+ ? room[method as string]
394
+ : (await room[method as string].apply(room, args && JSON.parse(JSON.stringify(args))));
395
+ }
396
+ }
397
+
398
+ export function defineRoomType<T extends Type<Room>>(
399
+ roomName: string,
400
+ klass: T,
401
+ defaultOptions?: OnCreateOptions<T>,
402
+ ) {
403
+ const registeredHandler = new RegisteredHandler(klass, defaultOptions);
404
+ registeredHandler.name = roomName;
405
+
406
+ handlers[roomName] = registeredHandler;
407
+
408
+ if (klass.prototype['onAuth'] !== Room.prototype['onAuth']) {
409
+ // TODO: soft-deprecate instance level `onAuth` on 0.16
410
+ // logger.warn("DEPRECATION WARNING: onAuth() at the instance level will be deprecated soon. Please use static onAuth() instead.");
411
+
412
+ if (klass['onAuth'] !== Room['onAuth']) {
413
+ logger.info(`❌ "${roomName}"'s onAuth() defined at the instance level will be ignored.`);
414
+ }
415
+ }
416
+
417
+ return registeredHandler;
418
+ }
419
+
420
+ export function addRoomType(handler: RegisteredHandler) {
421
+ handlers[handler.name] = handler;
422
+ }
423
+
424
+ export function removeRoomType(roomName: string) {
425
+ delete handlers[roomName];
426
+ }
427
+
428
+ export function getAllHandlers() {
429
+ return handlers;
430
+ }
431
+
432
+ export function getHandler(roomName: string) {
433
+ const handler = handlers[roomName];
434
+
435
+ if (!handler) {
436
+ throw new ServerError(ErrorCode.MATCHMAKE_NO_HANDLER, `provided room name "${roomName}" not defined`);
437
+ }
438
+
439
+ return handler;
440
+ }
441
+
442
+ export function getRoomClass(roomName: string): Type<Room> {
443
+ return handlers[roomName]?.klass;
444
+ }
445
+
446
+
447
+ /**
448
+ * Creates a new room.
449
+ *
450
+ * @param roomName - The identifier you defined on `gameServer.define()`
451
+ * @param clientOptions - Options for `onCreate`
452
+ *
453
+ * @returns Promise<IRoomCache> - A promise contaning an object which includes room metadata and configurations.
454
+ */
455
+ export async function createRoom(roomName: string, clientOptions: ClientOptions): Promise<IRoomCache> {
456
+ //
457
+ // - select a process to create the room
458
+ // - use local processId if MatchMaker is not ready yet
459
+ //
460
+ const selectedProcessId = (state === MatchMakerState.READY)
461
+ ? await selectProcessIdToCreateRoom(roomName, clientOptions)
462
+ : processId;
463
+
464
+ let room: IRoomCache;
465
+ if (selectedProcessId === undefined) {
466
+
467
+ if (isDevMode && processId === undefined) {
468
+ //
469
+ // WORKAROUND: wait for processId to be available
470
+ // TODO: Remove this check on 1.0
471
+ //
472
+ // - This is a workaround when using matchMaker.createRoom() before the processId is available.
473
+ // - We need to use top-level await to retrieve processId
474
+ //
475
+ await onReady;
476
+ return createRoom(roomName, clientOptions);
477
+
478
+ } else {
479
+ throw new ServerError(ErrorCode.MATCHMAKE_UNHANDLED, `no processId available to create room ${roomName}`);
480
+ }
481
+
482
+ } else if (selectedProcessId === processId) {
483
+ // create the room on this process!
484
+ room = await handleCreateRoom(roomName, clientOptions);
485
+
486
+ } else {
487
+ // ask other process to create the room!
488
+ try {
489
+ room = await requestFromIPC<IRoomCache>(
490
+ presence,
491
+ getProcessChannel(selectedProcessId),
492
+ undefined,
493
+ [roomName, clientOptions],
494
+ REMOTE_ROOM_SHORT_TIMEOUT,
495
+ );
496
+
497
+ } catch (e: any) {
498
+ if (e.message === "ipc_timeout") {
499
+ debugAndPrintError(`${e.message}: create room request timed out for ${roomName} on processId ${selectedProcessId}.`);
500
+
501
+ //
502
+ // clean-up possibly stale process from redis.
503
+ // when a process disconnects ungracefully, it may leave its previous processId under "roomcount"
504
+ // if the process is still alive, it will re-add itself shortly after the load-balancer selects it again.
505
+ //
506
+ if (enableHealthChecks) {
507
+ await stats.excludeProcess(selectedProcessId);
508
+ }
509
+
510
+ // if other process failed to respond, create the room on this process
511
+ room = await handleCreateRoom(roomName, clientOptions);
512
+
513
+ } else {
514
+ // re-throw intentional exception thrown during remote onCreate()
515
+ throw e;
516
+ }
517
+ }
518
+ }
519
+
520
+ if (isDevMode) {
521
+ presence.hset(getRoomRestoreListKey(), room.roomId, JSON.stringify({
522
+ "clientOptions": clientOptions,
523
+ "roomName": roomName,
524
+ "processId": processId
525
+ }));
526
+ }
527
+
528
+ return room;
529
+ }
530
+
531
+ export async function handleCreateRoom(roomName: string, clientOptions: ClientOptions, restoringRoomId?: string): Promise<IRoomCache> {
532
+ const handler = getHandler(roomName);
533
+ const room: Room = new handler.klass();
534
+
535
+ // set room public attributes
536
+ if (restoringRoomId && isDevMode) {
537
+ room.roomId = restoringRoomId;
538
+
539
+ } else {
540
+ room.roomId = generateId();
541
+ }
542
+
543
+ //
544
+ // Initialize .state (if set).
545
+ //
546
+ // Define getters and setters for:
547
+ // - autoDispose
548
+ // - patchRate
549
+ //
550
+ room['__init']();
551
+
552
+ room.roomName = roomName;
553
+ room.presence = createScopedPresence(room, presence);
554
+
555
+ // initialize a RoomCache instance
556
+ room['_listing'] = initializeRoomCache({
557
+ name: roomName,
558
+ processId,
559
+ ...handler.getMetadataFromOptions(clientOptions)
560
+ });
561
+
562
+ // assign public host
563
+ if (publicAddress) {
564
+ room['_listing'].publicAddress = publicAddress;
565
+ }
566
+
567
+ if (room.onCreate) {
568
+ try {
569
+ await room.onCreate(merge({}, clientOptions, handler.options));
570
+
571
+ } catch (e: any) {
572
+ debugAndPrintError(e);
573
+ throw new ServerError(
574
+ e.code || ErrorCode.MATCHMAKE_UNHANDLED,
575
+ e.message,
576
+ );
577
+ }
578
+ }
579
+
580
+ room['_internalState'] = RoomInternalState.CREATED;
581
+
582
+ room['_listing'].roomId = room.roomId;
583
+ room['_listing'].maxClients = room.maxClients;
584
+
585
+ // imediatelly ask client to join the room
586
+ debugMatchMaking('spawning \'%s\', roomId: %s, processId: %s', roomName, room.roomId, processId);
587
+
588
+ // increment amount of rooms this process is handling
589
+ stats.local.roomCount++;
590
+ stats.persist();
591
+
592
+ room['_events'].on('lock', lockRoom.bind(undefined, room));
593
+ room['_events'].on('unlock', unlockRoom.bind(undefined, room));
594
+ room['_events'].on('join', onClientJoinRoom.bind(undefined, room));
595
+ room['_events'].on('leave', onClientLeaveRoom.bind(undefined, room));
596
+ room['_events'].once('dispose', disposeRoom.bind(undefined, roomName, room));
597
+
598
+ if (handler.realtimeListingEnabled) {
599
+ room['_events'].on('visibility-change', onVisibilityChange.bind(undefined, room));
600
+ room['_events'].on('metadata-change', onMetadataChange.bind(undefined, room));
601
+ }
602
+
603
+ // when disconnect()'ing, keep only join/leave events for stat counting
604
+ room['_events'].once('disconnect', () => {
605
+ room['_events'].removeAllListeners('lock');
606
+ room['_events'].removeAllListeners('unlock');
607
+ room['_events'].removeAllListeners('dispose');
608
+
609
+ if (handler.realtimeListingEnabled) {
610
+ room['_events'].removeAllListeners('visibility-change');
611
+ room['_events'].removeAllListeners('metadata-change');
612
+ }
613
+
614
+ //
615
+ // emit "no active rooms" event when there are no more rooms in this process
616
+ // (used during graceful shutdown)
617
+ //
618
+ if (stats.local.roomCount <= 0) {
619
+ events.emit('no-active-rooms');
620
+ }
621
+ });
622
+
623
+ // room always start unlocked
624
+ await createRoomReferences(room, true);
625
+
626
+ // persist room data only if match-making is enabled
627
+ if (state !== MatchMakerState.SHUTTING_DOWN) {
628
+ await driver.persist(room['_listing'], true);
629
+ }
630
+
631
+ handler.emit('create', room);
632
+
633
+ return room['_listing'];
634
+ }
635
+
636
+ /**
637
+ * Get room data by roomId.
638
+ * This method does not return the actual room instance, use `getLocalRoomById` for that.
639
+ */
640
+ export function getRoomById(roomId: string) {
641
+ return driver.findOne({ roomId });
642
+ }
643
+
644
+ /**
645
+ * Get local room instance by roomId. (Can return "undefined" if the room is not available on this process)
646
+ */
647
+ export function getLocalRoomById(roomId: string) {
648
+ return rooms[roomId];
649
+ }
650
+
651
+ /**
652
+ * Disconnects every client on every room in the current process.
653
+ */
654
+ export function disconnectAll(closeCode?: number) {
655
+ const promises: Array<Promise<any>> = [];
656
+
657
+ for (const roomId in rooms) {
658
+ if (!rooms.hasOwnProperty(roomId)) {
659
+ continue;
660
+ }
661
+
662
+ promises.push(rooms[roomId].disconnect(closeCode));
663
+ }
664
+
665
+ return promises;
666
+ }
667
+
668
+ async function lockAndDisposeAll(): Promise<any> {
669
+ // remove processId from room count key
670
+ // (stops accepting new rooms on this process)
671
+ await stats.excludeProcess(processId);
672
+
673
+ // clear auto-persisting stats interval
674
+ if (enableHealthChecks) {
675
+ stats.clearAutoPersistInterval();
676
+ }
677
+
678
+ const noActiveRooms = new Deferred();
679
+ if (stats.local.roomCount <= 0) {
680
+ // no active rooms to dispose
681
+ noActiveRooms.resolve();
682
+
683
+ } else {
684
+ // wait for all rooms to be disposed
685
+ // TODO: set generous timeout in case
686
+ events.once('no-active-rooms', () => noActiveRooms.resolve());
687
+ }
688
+
689
+ // - lock all local rooms to prevent new joins
690
+ // - trigger `onBeforeShutdown()` on each room
691
+ for (const roomId in rooms) {
692
+ if (!rooms.hasOwnProperty(roomId)) {
693
+ continue;
694
+ }
695
+
696
+ const room = rooms[roomId];
697
+ room.lock();
698
+
699
+ if (isDevMode) {
700
+ // call default implementation of onBeforeShutdown() in dev mode
701
+ Room.prototype.onBeforeShutdown.call(room);
702
+
703
+ } else {
704
+ // call custom implementation of onBeforeShutdown() in production
705
+ room.onBeforeShutdown();
706
+ }
707
+ }
708
+
709
+ await noActiveRooms;
710
+ }
711
+
712
+ export async function gracefullyShutdown(): Promise<any> {
713
+ if (state === MatchMakerState.SHUTTING_DOWN) {
714
+ return Promise.reject('already_shutting_down');
715
+ }
716
+
717
+ debugMatchMaking(`${processId} is shutting down!`);
718
+
719
+ state = MatchMakerState.SHUTTING_DOWN;
720
+
721
+ onReady = undefined;
722
+
723
+ if (isDevMode) {
724
+ await cacheRoomHistory(rooms);
725
+ }
726
+
727
+ // - lock existing rooms
728
+ // - stop accepting new rooms on this process
729
+ // - wait for all rooms to be disposed
730
+ await lockAndDisposeAll();
731
+
732
+ // make sure rooms are removed from cache
733
+ await removeRoomsByProcessId(processId);
734
+
735
+ // unsubscribe from process id channel
736
+ presence.unsubscribe(getProcessChannel());
737
+
738
+ // make sure all rooms are disposed
739
+ return Promise.all(disconnectAll(
740
+ (isDevMode)
741
+ ? CloseCode.DEVMODE_RESTART
742
+ : CloseCode.SERVER_SHUTDOWN
743
+ ));
744
+ }
745
+
746
+ /**
747
+ * Reserve a seat for a client in a room
748
+ */
749
+ export async function reserveSeatFor(room: IRoomCache, options: ClientOptions, authData?: any) {
750
+ const sessionId: string = authData?.sessionId || generateId();
751
+
752
+ let successfulSeatReservation: boolean;
753
+
754
+ try {
755
+ successfulSeatReservation = await remoteRoomCall<Room>(
756
+ room.roomId,
757
+ '_reserveSeat' as keyof Room,
758
+ [sessionId, options, authData],
759
+ REMOTE_ROOM_SHORT_TIMEOUT,
760
+ );
761
+
762
+ } catch (e: any) {
763
+ debugMatchMaking(e);
764
+
765
+ //
766
+ // the room cache from an unavailable process might've been used here.
767
+ // (this is a broken state when a process wasn't gracefully shut down)
768
+ // perform a health-check on the process before proceeding.
769
+ //
770
+ if (
771
+ e.message === "ipc_timeout" &&
772
+ !(
773
+ enableHealthChecks &&
774
+ await healthCheckProcessId(room.processId)
775
+ )
776
+ ) {
777
+ throw new SeatReservationError(`process ${room.processId} is not available.`);
778
+
779
+ } else {
780
+ successfulSeatReservation = false;
781
+ }
782
+ }
783
+
784
+ if (!successfulSeatReservation) {
785
+ throw new SeatReservationError(`${room.roomId} is already full.`);
786
+ }
787
+
788
+ return buildSeatReservation(room, sessionId);
789
+ }
790
+
791
+ /**
792
+ * Reserve multiple seats for clients in a room
793
+ */
794
+ export async function reserveMultipleSeatsFor(room: IRoomCache, clientsData: Array<{ sessionId: string, options: ClientOptions, auth: any }>) {
795
+ let sessionIds: string[] = [];
796
+ let options: ClientOptions[] = [];
797
+ let authData: any[] = [];
798
+
799
+ for (const clientData of clientsData) {
800
+ sessionIds.push(clientData.sessionId);
801
+ options.push(clientData.options);
802
+ authData.push(clientData.auth);
803
+ }
804
+
805
+ debugMatchMaking(
806
+ 'reserving multiple seats. sessionIds: \'%s\', roomId: \'%s\', processId: \'%s\'',
807
+ sessionIds.join(', '), room.roomId, processId,
808
+ );
809
+
810
+ let successfulSeatReservations: boolean[];
811
+
812
+ try {
813
+ successfulSeatReservations = await remoteRoomCall<Room>(
814
+ room.roomId,
815
+ '_reserveMultipleSeats' as keyof Room,
816
+ [sessionIds, options, authData],
817
+ REMOTE_ROOM_SHORT_TIMEOUT,
818
+ );
819
+
820
+ } catch (e: any) {
821
+ debugMatchMaking(e);
822
+
823
+ //
824
+ // the room cache from an unavailable process might've been used here.
825
+ // (this is a broken state when a process wasn't gracefully shut down)
826
+ // perform a health-check on the process before proceeding.
827
+ //
828
+ if (
829
+ e.message === "ipc_timeout" &&
830
+ !(
831
+ enableHealthChecks &&
832
+ await healthCheckProcessId(room.processId)
833
+ )
834
+ ) {
835
+ throw new SeatReservationError(`process ${room.processId} is not available.`);
836
+
837
+ } else {
838
+ throw new SeatReservationError(`${room.roomId} is already full.`);
839
+ }
840
+ }
841
+
842
+ return successfulSeatReservations;
843
+ }
844
+
845
+ /**
846
+ * Build a seat reservation object.
847
+ * @param room - The room to build a seat reservation for.
848
+ * @param sessionId - The session ID of the client.
849
+ * @returns A seat reservation object.
850
+ */
851
+ export function buildSeatReservation(room: IRoomCache, sessionId: string) {
852
+ const seatReservation: ISeatReservation = {
853
+ name: room.name,
854
+ sessionId,
855
+ roomId: room.roomId,
856
+ processId: room.processId,
857
+ };
858
+
859
+ if (isDevMode) {
860
+ seatReservation.devMode = isDevMode;
861
+ }
862
+
863
+ if (room.publicAddress) {
864
+ seatReservation.publicAddress = room.publicAddress;
865
+ }
866
+
867
+ return seatReservation;
868
+ }
869
+
870
+ async function callOnAuth(roomName: string, clientOptions?: ClientOptions, authContext?: AuthContext) {
871
+ const roomClass = getRoomClass(roomName);
872
+ if (roomClass && roomClass['onAuth'] && roomClass['onAuth'] !== Room['onAuth']) {
873
+ const result = await roomClass['onAuth'](authContext.token, clientOptions, authContext)
874
+ if (!result) {
875
+ throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');
876
+ }
877
+ return result;
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Perform health check on all processes
883
+ */
884
+ export async function healthCheckAllProcesses() {
885
+ const allStats = await stats.fetchAll();
886
+ const activeProcessChannels = (await presence.channels("p:*")).map(c => c.substring(2));
887
+
888
+ if (allStats.length > 0) {
889
+ await Promise.all(
890
+ allStats
891
+ .filter(stat => (
892
+ stat.processId !== processId && // skip current process
893
+ !activeProcessChannels.includes(stat.processId) // skip if channel is still listening
894
+ ))
895
+ .map(stat => healthCheckProcessId(stat.processId))
896
+ );
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Perform health check on a remote process
902
+ * @param processId
903
+ */
904
+ const _healthCheckByProcessId: { [processId: string]: Promise<any> } = {};
905
+ export function healthCheckProcessId(processId: string) {
906
+ //
907
+ // re-use the same promise if health-check is already in progress
908
+ // (may occur when _reserveSeat() fails multiple times for the same 'processId')
909
+ //
910
+ if (_healthCheckByProcessId[processId] !== undefined) {
911
+ return _healthCheckByProcessId[processId];
912
+ }
913
+
914
+ _healthCheckByProcessId[processId] = new Promise<boolean>(async (resolve, reject) => {
915
+ logger.debug(`> Performing health-check against processId: '${processId}'...`);
916
+
917
+ try {
918
+ const requestTime = Date.now();
919
+
920
+ await requestFromIPC<IRoomCache>(
921
+ presence,
922
+ getProcessChannel(processId),
923
+ 'healthcheck',
924
+ [],
925
+ REMOTE_ROOM_SHORT_TIMEOUT,
926
+ );
927
+
928
+ logger.debug(`✅ Process '${processId}' successfully responded (${Date.now() - requestTime}ms)`);
929
+
930
+ // succeeded to respond
931
+ resolve(true)
932
+
933
+ } catch (e) {
934
+ // process failed to respond - remove it from stats
935
+ logger.debug(`❌ Process '${processId}' failed to respond. Cleaning it up.`);
936
+ await stats.excludeProcess(processId);
937
+
938
+ // clean-up possibly stale room ids
939
+ if (!isDevMode) {
940
+ await removeRoomsByProcessId(processId);
941
+ }
942
+
943
+ resolve(false);
944
+ } finally {
945
+ delete _healthCheckByProcessId[processId];
946
+ }
947
+ });
948
+
949
+ return _healthCheckByProcessId[processId];
950
+ }
951
+
952
+ /**
953
+ * Remove cached rooms by processId
954
+ * @param processId
955
+ */
956
+ async function removeRoomsByProcessId(processId: string) {
957
+ //
958
+ // clean-up possibly stale room ids
959
+ // (ungraceful shutdowns using Redis can result on stale room ids still on memory.)
960
+ //
961
+ await driver.cleanup(processId);
962
+ }
963
+
964
+ async function createRoomReferences(room: Room, init: boolean = false): Promise<boolean> {
965
+ rooms[room.roomId] = room;
966
+
967
+ if (init) {
968
+ await subscribeIPC(
969
+ presence,
970
+ getRoomChannel(room.roomId),
971
+ (method, args) => {
972
+ return (!args && typeof (room[method]) !== 'function')
973
+ ? room[method]
974
+ : room[method].apply(room, args);
975
+ },
976
+ );
977
+ }
978
+
979
+ return true;
980
+ }
981
+
982
+ /**
983
+ * Used only during `joinOrCreate` to handle concurrent requests for creating a room.
984
+ */
985
+ async function concurrentJoinOrCreateRoomLock(
986
+ handler: RegisteredHandler,
987
+ concurrencyKey: string,
988
+ callback: (roomId?: string) => Promise<IRoomCache>
989
+ ): Promise<IRoomCache> {
990
+ return new Promise(async (resolve, reject) => {
991
+ const hkey = getConcurrencyHashKey(handler.name);
992
+ const concurrency = await presence.hincrbyex(
993
+ hkey,
994
+ concurrencyKey,
995
+ 1, // increment by 1
996
+ MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME * 2 // expire in 2x the time of MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME
997
+ ) - 1; // do not consider the current request
998
+
999
+ const fulfill = async (roomId?: string) => {
1000
+ try {
1001
+ resolve(await callback(roomId));
1002
+
1003
+ } catch (e) {
1004
+ reject(e);
1005
+
1006
+ } finally {
1007
+ await presence.hincrbyex(hkey, concurrencyKey, -1, MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME * 2);
1008
+ }
1009
+ };
1010
+
1011
+ if (concurrency > 0) {
1012
+ debugMatchMaking(
1013
+ 'receiving %d concurrent joinOrCreate for \'%s\' (%s)',
1014
+ concurrency, handler.name, concurrencyKey
1015
+ );
1016
+
1017
+ try {
1018
+ const roomId = await subscribeWithTimeout(
1019
+ presence,
1020
+ `concurrent:${handler.name}:${concurrencyKey}`,
1021
+ (MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME +
1022
+ (Math.min(concurrency, 3) * 0.2)) * 1000 // convert to milliseconds
1023
+ );
1024
+
1025
+ return await fulfill(roomId);
1026
+ } catch (error) {
1027
+ // Ignore ipc_timeout error
1028
+ }
1029
+ }
1030
+
1031
+ return await fulfill();
1032
+ });
1033
+ }
1034
+
1035
+ function onClientJoinRoom(room: Room, client: Client) {
1036
+ // increment local CCU
1037
+ stats.local.ccu++;
1038
+ stats.persist();
1039
+
1040
+ handlers[room.roomName].emit('join', room, client);
1041
+ }
1042
+
1043
+ function onClientLeaveRoom(room: Room, client: Client, willDispose: boolean) {
1044
+ // decrement local CCU
1045
+ stats.local.ccu--;
1046
+ stats.persist();
1047
+
1048
+ handlers[room.roomName].emit('leave', room, client, willDispose);
1049
+ }
1050
+
1051
+ function lockRoom(room: Room): void {
1052
+ // emit public event on registered handler
1053
+ handlers[room.roomName].emit('lock', room);
1054
+ }
1055
+
1056
+ async function unlockRoom(room: Room) {
1057
+ if (await createRoomReferences(room)) {
1058
+ // emit public event on registered handler
1059
+ handlers[room.roomName].emit('unlock', room);
1060
+ }
1061
+ }
1062
+
1063
+ function onVisibilityChange(room: Room, isInvisible: boolean): void {
1064
+ handlers[room.roomName].emit('visibility-change', room, isInvisible);
1065
+ }
1066
+
1067
+ function onMetadataChange(room: Room): void {
1068
+ handlers[room.roomName].emit('metadata-change', room);
1069
+ }
1070
+
1071
+ async function disposeRoom(roomName: string, room: Room) {
1072
+ debugMatchMaking('disposing \'%s\' (%s) on processId \'%s\' (graceful shutdown: %s)', roomName, room.roomId, processId, state === MatchMakerState.SHUTTING_DOWN);
1073
+
1074
+ //
1075
+ // FIXME: this call should not be necessary.
1076
+ //
1077
+ // there's an unidentified edge case using LocalDriver where Room._dispose()
1078
+ // doesn't seem to be called [?], but "disposeRoom" is, leaving the matchmaker
1079
+ // in a broken state. (repeated ipc_timeout's for seat reservation on
1080
+ // non-existing rooms)
1081
+ //
1082
+ driver.remove(room['_listing'].roomId);
1083
+ stats.local.roomCount--;
1084
+
1085
+ // decrease amount of rooms this process is handling
1086
+ if (state !== MatchMakerState.SHUTTING_DOWN) {
1087
+ stats.persist();
1088
+
1089
+ // remove from devMode restore list
1090
+ if (isDevMode) {
1091
+ await presence.hdel(getRoomRestoreListKey(), room.roomId);
1092
+ }
1093
+ }
1094
+
1095
+ // emit disposal on registered session handler
1096
+ handlers[roomName].emit('dispose', room);
1097
+
1098
+ // unsubscribe from remote connections
1099
+ presence.unsubscribe(getRoomChannel(room.roomId));
1100
+
1101
+ // remove actual room reference
1102
+ delete rooms[room.roomId];
1103
+ }
1104
+
1105
+ //
1106
+ // Presence keys
1107
+ //
1108
+ function getRoomChannel(roomId: string) {
1109
+ return `$${roomId}`;
1110
+ }
1111
+
1112
+ function getConcurrencyHashKey(roomName: string) {
1113
+ // concurrency hash
1114
+ return `ch:${roomName}`;
1115
+ }
1116
+
1117
+ function getProcessChannel(id: string = processId) {
1118
+ return `p:${id}`;
1119
+ }