@colyseus/core 0.18.16 → 0.18.18

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 (38) hide show
  1. package/build/MatchMaker.cjs +93 -10
  2. package/build/MatchMaker.cjs.map +3 -3
  3. package/build/MatchMaker.d.ts +20 -1
  4. package/build/MatchMaker.mjs +93 -11
  5. package/build/MatchMaker.mjs.map +3 -3
  6. package/build/Room.cjs +69 -68
  7. package/build/Room.cjs.map +2 -2
  8. package/build/Room.mjs +69 -68
  9. package/build/Room.mjs.map +2 -2
  10. package/build/matchmaker/LocalDriver/LocalDriver.cjs +14 -1
  11. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  12. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +2 -0
  13. package/build/matchmaker/LocalDriver/LocalDriver.mjs +14 -1
  14. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  15. package/build/matchmaker/driver.cjs.map +1 -1
  16. package/build/matchmaker/driver.d.ts +11 -0
  17. package/build/matchmaker/driver.mjs.map +1 -1
  18. package/build/presence/LocalPresence.cjs +2 -0
  19. package/build/presence/LocalPresence.cjs.map +2 -2
  20. package/build/presence/LocalPresence.mjs +2 -0
  21. package/build/presence/LocalPresence.mjs.map +2 -2
  22. package/build/utils/DevMode.cjs +3 -0
  23. package/build/utils/DevMode.cjs.map +2 -2
  24. package/build/utils/DevMode.mjs +4 -1
  25. package/build/utils/DevMode.mjs.map +2 -2
  26. package/build/utils/Utils.cjs +6 -0
  27. package/build/utils/Utils.cjs.map +2 -2
  28. package/build/utils/Utils.d.ts +2 -0
  29. package/build/utils/Utils.mjs +4 -0
  30. package/build/utils/Utils.mjs.map +2 -2
  31. package/package.json +3 -3
  32. package/src/MatchMaker.ts +174 -16
  33. package/src/Room.ts +72 -30
  34. package/src/matchmaker/LocalDriver/LocalDriver.ts +15 -7
  35. package/src/matchmaker/driver.ts +12 -0
  36. package/src/presence/LocalPresence.ts +2 -0
  37. package/src/utils/DevMode.ts +4 -1
  38. package/src/utils/Utils.ts +6 -0
package/src/Room.ts CHANGED
@@ -65,6 +65,9 @@ const noneSerializer = new NoneSerializer();
65
65
  * next patch. Frozen + shared (read-only) → zero per-call allocation. */
66
66
  const AFTER_PATCH_OPTS = Object.freeze({ afterNextPatch: true });
67
67
 
68
+ /** Room properties backed by `Room.prototype` accessors. */
69
+ const ROOM_ACCESSORS = ['state', 'maxClients', 'autoDispose', 'patchRate', 'unreliablePatchRate'] as const;
70
+
68
71
  export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
69
72
 
70
73
  export type SimulationCallback = (deltaTime: number) => void;
@@ -258,25 +261,25 @@ export class Room<T extends RoomOptions = RoomOptions> {
258
261
  * it is locked automatically. Unless the room was explicitly locked by you via `lock()` method,
259
262
  * the room will be unlocked as soon as a client disconnects from it.
260
263
  */
261
- public maxClients: number = Infinity;
264
+ declare public maxClients: number;
262
265
  #_maxClientsReached: boolean = false;
263
- #_maxClients: number;
266
+ #_maxClients: number = Infinity;
264
267
 
265
268
  /**
266
269
  * Automatically dispose the room when last client disconnects.
267
270
  *
268
271
  * @default true
269
272
  */
270
- public autoDispose: boolean = true;
271
- #_autoDispose: boolean;
273
+ declare public autoDispose: boolean;
274
+ #_autoDispose: boolean = true;
272
275
 
273
276
  /**
274
277
  * Frequency to send the room state to connected clients, in milliseconds.
275
278
  *
276
279
  * @default 50ms (20fps)
277
280
  */
278
- public patchRate: number | null = DEFAULT_PATCH_RATE;
279
- #_patchRate: number;
281
+ declare public patchRate: number | null;
282
+ #_patchRate: number = DEFAULT_PATCH_RATE;
280
283
  #_patchInterval: NodeJS.Timeout;
281
284
 
282
285
  /**
@@ -305,7 +308,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
305
308
  * @default null — flush alongside every {@link broadcastPatch}, and only when
306
309
  * the state actually declares an `@unreliable` field.
307
310
  */
308
- public unreliablePatchRate: number | null = null;
311
+ declare public unreliablePatchRate: number | null;
309
312
  #_unreliablePatchRate: number | null = null;
310
313
  #_unreliablePatchInterval: NodeJS.Timeout;
311
314
 
@@ -329,9 +332,12 @@ export class Room<T extends RoomOptions = RoomOptions> {
329
332
  /**
330
333
  * The state instance you provided to `setState()`.
331
334
  */
332
- public state: ExtractRoomState<T>;
335
+ declare public state: ExtractRoomState<T>;
333
336
  #_state: ExtractRoomState<T>;
334
337
 
338
+ /** Until `__init()`, the prototype accessor setters only store the value. */
339
+ #_ready: boolean = false;
340
+
335
341
  /**
336
342
  * The presence instance. Check Presence API for more details.
337
343
  *
@@ -455,21 +461,30 @@ export class Room<T extends RoomOptions = RoomOptions> {
455
461
  }
456
462
 
457
463
  /**
458
- * This method is called by the MatchMaker before onCreate()
459
- * @internal
464
+ * These accessors are defined once, on the prototype, to keep rooms in V8's
465
+ * fast-properties mode.
466
+ *
467
+ * V8 reads a property of an ordinary object at a fixed offset. A few
468
+ * operations switch the object to "dictionary mode" instead, where every
469
+ * read is a hash lookup, and it stays there:
470
+ * - `delete obj.prop`, unless `prop` was the last property added;
471
+ * - redefining an existing data property as an accessor;
472
+ * - `Object.defineProperty(instance, ...)` with fresh getter/setter
473
+ * closures per instance (every room after the first).
474
+ *
475
+ * Room and Client properties are read on every message and broadcast, so
476
+ * never `delete` their properties (assign `undefined`) and never define
477
+ * accessors per instance. Check with `node --allow-natives-syntax` and
478
+ * `%HasFastProperties(room)`.
460
479
  */
461
- private __init() {
462
- this.#_state = this.state;
463
- this.#_autoDispose = this.autoDispose;
464
- this.#_patchRate = this.patchRate;
465
- this.#_unreliablePatchRate = this.unreliablePatchRate;
466
- this.#_maxClients = this.maxClients;
467
-
468
- Object.defineProperties(this, {
480
+ static {
481
+ Object.defineProperties(this.prototype, {
469
482
  state: {
470
483
  enumerable: true,
471
- get: () => this.#_state,
472
- set: (newState: ExtractRoomState<T>) => {
484
+ configurable: true,
485
+ get(this: Room<any>) { return this.#_state; },
486
+ set(this: Room<any>, newState: any) {
487
+ if (!this.#_ready) { this.#_state = newState; return; }
473
488
  if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) {
474
489
  this.setSerializer(new SchemaSerializer());
475
490
  } else if ('_definition' in newState) {
@@ -485,16 +500,20 @@ export class Room<T extends RoomOptions = RoomOptions> {
485
500
 
486
501
  maxClients: {
487
502
  enumerable: true,
488
- get: () => this.#_maxClients,
489
- set: (value: number) => {
503
+ configurable: true,
504
+ get(this: Room<any>) { return this.#_maxClients; },
505
+ set(this: Room<any>, value: number) {
506
+ if (!this.#_ready) { this.#_maxClients = value; return; }
490
507
  this.setMatchmaking({ maxClients: value });
491
508
  },
492
509
  },
493
510
 
494
511
  autoDispose: {
495
512
  enumerable: true,
496
- get: () => this.#_autoDispose,
497
- set: (value: boolean) => {
513
+ configurable: true,
514
+ get(this: Room<any>) { return this.#_autoDispose; },
515
+ set(this: Room<any>, value: boolean) {
516
+ if (!this.#_ready) { this.#_autoDispose = value; return; }
498
517
  if (
499
518
  value !== this.#_autoDispose &&
500
519
  this._internalState !== RoomInternalState.DISPOSING
@@ -507,9 +526,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
507
526
 
508
527
  patchRate: {
509
528
  enumerable: true,
510
- get: () => this.#_patchRate,
511
- set: (milliseconds: number) => {
529
+ configurable: true,
530
+ get(this: Room<any>) { return this.#_patchRate; },
531
+ set(this: Room<any>, milliseconds: number) {
512
532
  this.#_patchRate = milliseconds;
533
+ if (!this.#_ready) { return; }
513
534
  // clear previous interval in case called setPatchRate more than once
514
535
  if (this.#_patchInterval) {
515
536
  clearInterval(this.#_patchInterval);
@@ -526,13 +547,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
526
547
 
527
548
  unreliablePatchRate: {
528
549
  enumerable: true,
529
- get: () => this.#_unreliablePatchRate,
530
- set: (milliseconds: number | null) => {
550
+ configurable: true,
551
+ get(this: Room<any>) { return this.#_unreliablePatchRate; },
552
+ set(this: Room<any>, milliseconds: number | null) {
531
553
  this.#_unreliablePatchRate = milliseconds;
554
+ if (!this.#_ready) { return; }
532
555
  this._armUnreliablePatches();
533
556
  },
534
557
  },
535
558
  });
559
+ }
560
+
561
+ /**
562
+ * This method is called by the MatchMaker before onCreate()
563
+ * @internal
564
+ */
565
+ private __init() {
566
+ // Native class fields (plain JS, type-stripped TS, `useDefineForClassFields`) shadow
567
+ // the prototype accessors: re-install them here, at the cost of this room's fast mode.
568
+ for (let i = 0; i < ROOM_ACCESSORS.length; i++) {
569
+ const key = ROOM_ACCESSORS[i];
570
+ if (!Object.hasOwn(this, key)) { continue; }
571
+ const value = this[key];
572
+ Object.defineProperty(this, key, Object.getOwnPropertyDescriptor(Room.prototype, key));
573
+ (this as any)[key] = value; // not ready yet: only stores
574
+ }
575
+
576
+ this.#_ready = true;
536
577
 
537
578
  // set patch interval, now with the setter
538
579
  this.patchRate = this.#_patchRate;
@@ -2187,7 +2228,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
2187
2228
  async #_dispose(): Promise<any> {
2188
2229
  this._internalState = RoomInternalState.DISPOSING;
2189
2230
 
2190
- // If the room is still CREATING, the roomId is not yet set.
2231
+ // no row to remove: roomId is unset if onCreate() failed, and cleared when the
2232
+ // row belongs to another process (see `recordRoom`)
2191
2233
  if (this._listing?.roomId !== undefined) {
2192
2234
  await matchMaker.driver.remove(this._listing.roomId);
2193
2235
  }
@@ -2284,7 +2326,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
2284
2326
  if (client._enqueuedMessages.length > 0) {
2285
2327
  client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
2286
2328
  }
2287
- delete client._enqueuedMessages;
2329
+ client._enqueuedMessages = undefined; // not `delete`: keeps the client in V8 fast mode (see Room's static block)
2288
2330
 
2289
2331
  } else if (code === Protocol.PING) {
2290
2332
  client.raw(getMessageBytes[Protocol.PING]());
@@ -8,8 +8,11 @@ export type { IRoomCache, SortOptions, MatchMakerDriver };
8
8
  export class LocalDriver implements MatchMakerDriver {
9
9
  public rooms: IRoomCache[] = [];
10
10
 
11
+ // the roomIds in `rooms`: keeps has() and insert() O(1) however many rooms there are
12
+ private roomIds = new Set<string>();
13
+
11
14
  public has(roomId: string) {
12
- return this.rooms.some((room) => room.roomId === roomId);
15
+ return this.roomIds.has(roomId);
13
16
  }
14
17
 
15
18
  public query(conditions: Partial<IRoomCache>, sortOptions?: SortOptions) {
@@ -70,15 +73,18 @@ export class LocalDriver implements MatchMakerDriver {
70
73
  return true;
71
74
  }
72
75
 
73
- public persist(room: IRoomCache, create: boolean = false) {
74
- // if (this.rooms.indexOf(room) !== -1) {
75
- // // already in the list
76
- // return true;
77
- // }
76
+ public async insert(room: IRoomCache) {
77
+ if (this.has(room.roomId)) { return false; }
78
78
 
79
+ this.roomIds.add(room.roomId);
80
+ this.rooms.push(room);
81
+ return true;
82
+ }
83
+
84
+ public persist(room: IRoomCache, create: boolean = false) {
79
85
  if (!create) { return false; }
80
86
 
81
- // add to the list
87
+ this.roomIds.add(room.roomId);
82
88
  this.rooms.push(room);
83
89
 
84
90
  return true;
@@ -88,6 +94,7 @@ export class LocalDriver implements MatchMakerDriver {
88
94
  const roomIndex = this.rooms.findIndex((room) => room.roomId === roomId);
89
95
  if (roomIndex !== -1) {
90
96
  this.rooms.splice(roomIndex, 1);
97
+ this.roomIds.delete(roomId);
91
98
  return true;
92
99
  }
93
100
  return false;
@@ -95,6 +102,7 @@ export class LocalDriver implements MatchMakerDriver {
95
102
 
96
103
  public clear() {
97
104
  this.rooms = [];
105
+ this.roomIds.clear();
98
106
  }
99
107
 
100
108
  public shutdown() {
@@ -186,6 +186,18 @@ export interface MatchMakerDriver {
186
186
  */
187
187
  persist(room: IRoomCache, create?: boolean): Promise<boolean> | boolean;
188
188
 
189
+ /**
190
+ * Record a room, unless its `roomId` is already recorded. Must be atomic, and
191
+ * must return false rather than throwing when the `roomId` is taken.
192
+ *
193
+ * Matchmaking uses this to settle which process owns a room when one create
194
+ * request is answered twice. Drivers that don't implement it fall back to
195
+ * `persist(room, true)`, where the second write silently wins.
196
+ *
197
+ * TODO: required on 1.0, replacing `persist()`'s `create` flag.
198
+ */
199
+ insert?(room: IRoomCache): Promise<boolean>;
200
+
189
201
  /**
190
202
  * Empty the room cache. Used for testing purposes only.
191
203
  * May be asynchronous — callers must await it.
@@ -107,6 +107,8 @@ export class LocalPresence implements Presence {
107
107
  }
108
108
 
109
109
  public del(key: string) {
110
+ clearTimeout(this.timeouts[key]); // a pending expiry would wipe whatever is set next
111
+ delete this.timeouts[key];
110
112
  delete this.keys[key];
111
113
  delete this.data[key];
112
114
  delete this.hash[key];
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { type Schema, MapSchema, ArraySchema, SetSchema, CollectionSchema, $childType, $changes } from '@colyseus/schema';
4
4
  import { logger } from '../Logger.ts';
5
5
  import { debugAndPrintError, debugDevMode } from '../Debug.ts';
6
- import { getLocalRoomById, handleCreateRoom, presence, remoteRoomCall } from '../MatchMaker.ts';
6
+ import { getLocalRoomById, handleCreateRoom, MatchMakerState, presence, remoteRoomCall, state } from '../MatchMaker.ts';
7
7
  import type { Room } from '../Room.ts';
8
8
 
9
9
  const DEVMODE_CACHE_FILE_PATH = path.resolve(".devmode.json");
@@ -49,6 +49,9 @@ export async function reloadFromCache() {
49
49
  recreatedRoom = getLocalRoomById(recreatedRoomListing.roomId);
50
50
 
51
51
  } catch (e: any) {
52
+ // shutdown interrupted the restore: keep the remaining entries for the next boot
53
+ if (state === MatchMakerState.SHUTTING_DOWN) { break; }
54
+
52
55
  // unrestorable entry (e.g. room type renamed/removed) — drop it
53
56
  // instead of wedging the boot
54
57
  debugAndPrintError(`❌ couldn't restore room '${roomId}':\n${e.stack}`);
@@ -44,6 +44,12 @@ export type RemoteRoomCallReturn<
44
44
  export const REMOTE_ROOM_SHORT_TIMEOUT = Number(process.env.COLYSEUS_PRESENCE_SHORT_TIMEOUT || 2000);
45
45
  export const MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME = Number(process.env.COLYSEUS_MAX_CONCURRENT_CREATE_ROOM_WAIT_TIME || 0.5);
46
46
 
47
+ // a peer that removes this process's room listings leaves a receipt. this
48
+ // process looks for it every INTERVAL (ms) and restores them. the TTL (seconds)
49
+ // only bounds what dead processes leave behind.
50
+ export const SWEPT_CHECK_INTERVAL = Number(process.env.COLYSEUS_SWEPT_CHECK_INTERVAL || 5000);
51
+ export const SWEPT_RECEIPT_TTL = Number(process.env.COLYSEUS_SWEPT_RECEIPT_TTL || 600);
52
+
47
53
  /**
48
54
  * The well-known `Symbol.metadata` (TC39 decorator metadata), falling back to the
49
55
  * registered symbol on runtimes that don't expose it globally yet. `@colyseus/schema`