@colyseus/core 0.18.17 → 0.18.19

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.
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
  *
@@ -445,31 +451,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
445
451
  .catch((e) => debugAndPrintError(`onDispose error: ${(e && e.stack || e.message || e || 'promise rejected')} (roomId: ${this.roomId})`))
446
452
  .finally(() => this._events.emit('disconnect'));
447
453
  });
448
-
449
- /**
450
- * If `onUncaughtException` is defined, it will automatically catch exceptions
451
- */
452
- if (this.onUncaughtException !== undefined) {
453
- this.#registerUncaughtExceptionHandlers();
454
- }
455
454
  }
456
455
 
457
456
  /**
458
- * This method is called by the MatchMaker before onCreate()
459
- * @internal
457
+ * These accessors are defined once, on the prototype, to keep rooms in V8's
458
+ * fast-properties mode.
459
+ *
460
+ * V8 reads a property of an ordinary object at a fixed offset. A few
461
+ * operations switch the object to "dictionary mode" instead, where every
462
+ * read is a hash lookup, and it stays there:
463
+ * - `delete obj.prop`, unless `prop` was the last property added;
464
+ * - redefining an existing data property as an accessor;
465
+ * - `Object.defineProperty(instance, ...)` with fresh getter/setter
466
+ * closures per instance (every room after the first).
467
+ *
468
+ * Room and Client properties are read on every message and broadcast, so
469
+ * never `delete` their properties (assign `undefined`) and never define
470
+ * accessors per instance. Check with `node --allow-natives-syntax` and
471
+ * `%HasFastProperties(room)`.
460
472
  */
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, {
473
+ static {
474
+ Object.defineProperties(this.prototype, {
469
475
  state: {
470
476
  enumerable: true,
471
- get: () => this.#_state,
472
- set: (newState: ExtractRoomState<T>) => {
477
+ configurable: true,
478
+ get(this: Room<any>) { return this.#_state; },
479
+ set(this: Room<any>, newState: any) {
480
+ if (!this.#_ready) { this.#_state = newState; return; }
473
481
  if (newState?.constructor[Symbol.metadata] !== undefined || newState[$changes] !== undefined) {
474
482
  this.setSerializer(new SchemaSerializer());
475
483
  } else if ('_definition' in newState) {
@@ -485,16 +493,20 @@ export class Room<T extends RoomOptions = RoomOptions> {
485
493
 
486
494
  maxClients: {
487
495
  enumerable: true,
488
- get: () => this.#_maxClients,
489
- set: (value: number) => {
496
+ configurable: true,
497
+ get(this: Room<any>) { return this.#_maxClients; },
498
+ set(this: Room<any>, value: number) {
499
+ if (!this.#_ready) { this.#_maxClients = value; return; }
490
500
  this.setMatchmaking({ maxClients: value });
491
501
  },
492
502
  },
493
503
 
494
504
  autoDispose: {
495
505
  enumerable: true,
496
- get: () => this.#_autoDispose,
497
- set: (value: boolean) => {
506
+ configurable: true,
507
+ get(this: Room<any>) { return this.#_autoDispose; },
508
+ set(this: Room<any>, value: boolean) {
509
+ if (!this.#_ready) { this.#_autoDispose = value; return; }
498
510
  if (
499
511
  value !== this.#_autoDispose &&
500
512
  this._internalState !== RoomInternalState.DISPOSING
@@ -507,9 +519,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
507
519
 
508
520
  patchRate: {
509
521
  enumerable: true,
510
- get: () => this.#_patchRate,
511
- set: (milliseconds: number) => {
522
+ configurable: true,
523
+ get(this: Room<any>) { return this.#_patchRate; },
524
+ set(this: Room<any>, milliseconds: number) {
512
525
  this.#_patchRate = milliseconds;
526
+ if (!this.#_ready) { return; }
513
527
  // clear previous interval in case called setPatchRate more than once
514
528
  if (this.#_patchInterval) {
515
529
  clearInterval(this.#_patchInterval);
@@ -526,13 +540,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
526
540
 
527
541
  unreliablePatchRate: {
528
542
  enumerable: true,
529
- get: () => this.#_unreliablePatchRate,
530
- set: (milliseconds: number | null) => {
543
+ configurable: true,
544
+ get(this: Room<any>) { return this.#_unreliablePatchRate; },
545
+ set(this: Room<any>, milliseconds: number | null) {
531
546
  this.#_unreliablePatchRate = milliseconds;
547
+ if (!this.#_ready) { return; }
532
548
  this._armUnreliablePatches();
533
549
  },
534
550
  },
535
551
  });
552
+ }
553
+
554
+ /**
555
+ * This method is called by the MatchMaker before onCreate()
556
+ * @internal
557
+ */
558
+ private __init() {
559
+ // Native class fields (plain JS, type-stripped TS, `useDefineForClassFields`) shadow
560
+ // the prototype accessors: re-install them here, at the cost of this room's fast mode.
561
+ for (let i = 0; i < ROOM_ACCESSORS.length; i++) {
562
+ const key = ROOM_ACCESSORS[i];
563
+ if (!Object.hasOwn(this, key)) { continue; }
564
+ const value = this[key];
565
+ Object.defineProperty(this, key, Object.getOwnPropertyDescriptor(Room.prototype, key));
566
+ (this as any)[key] = value; // not ready yet: only stores
567
+ }
568
+
569
+ this.#_ready = true;
536
570
 
537
571
  // set patch interval, now with the setter
538
572
  this.patchRate = this.#_patchRate;
@@ -551,6 +585,12 @@ export class Room<T extends RoomOptions = RoomOptions> {
551
585
  setupRoomPlugins(this);
552
586
  }
553
587
 
588
+ // After setupRoomPlugins(), so the try/catch covers plugin hooks too.
589
+ // Not in the constructor: subclass fields aren't assigned until it returns.
590
+ if (this.onUncaughtException !== undefined) {
591
+ this.#registerUncaughtExceptionHandlers();
592
+ }
593
+
554
594
  // Bind messages to the room
555
595
  if (this.messages !== undefined) {
556
596
 
@@ -2285,7 +2325,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
2285
2325
  if (client._enqueuedMessages.length > 0) {
2286
2326
  client._enqueuedMessages.forEach((enqueued) => client.raw(enqueued));
2287
2327
  }
2288
- delete client._enqueuedMessages;
2328
+ client._enqueuedMessages = undefined; // not `delete`: keeps the client in V8 fast mode (see Room's static block)
2289
2329
 
2290
2330
  } else if (code === Protocol.PING) {
2291
2331
  client.raw(getMessageBytes[Protocol.PING]());
@@ -2446,7 +2486,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
2446
2486
  this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, 'onCreate', true);
2447
2487
  }
2448
2488
 
2449
- if (this.onAuth !== undefined) {
2489
+ // _onJoin skips onAuth only while it's still Room's default
2490
+ if (this.onAuth !== Room.prototype.onAuth) {
2450
2491
  this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, 'onAuth', true);
2451
2492
  }
2452
2493
 
package/src/RoomPlugin.ts CHANGED
@@ -435,31 +435,52 @@ export function installPluginHookWrappers(
435
435
  for (const hook of PLUGIN_LIFECYCLE_KEYS) {
436
436
  const { before, after } = layout.hooks[hook];
437
437
  if (before.length === 0 && after.length === 0) { continue; }
438
+ proto[hook] = composePluginHook(hook, before, after, proto[hook]);
439
+ }
440
+ }
438
441
 
439
- const original = proto[hook] as Function | undefined;
440
- proto[hook] = async function (
441
- this: { plugins?: Record<string, RoomPlugin>; _autoPlugins?: Record<string, RoomPlugin> },
442
- ...args: any[]
443
- ) {
444
- // User plugins live on `this.plugins`, auto-deps (sentinel-keyed)
445
- // on `this._autoPlugins` — kept separate so user types stay clean.
446
- const lookup = (k: string): RoomPlugin =>
447
- (k.startsWith(DEP_KEY_PREFIX) ? this._autoPlugins![k] : this.plugins![k]);
448
- for (const k of before) {
449
- const p = lookup(k);
450
- await (p[hook] as Function).call(p, ...args);
451
- }
452
- let result: unknown;
453
- if (original) { result = await original.apply(this, args); }
454
- for (const k of after) {
455
- const p = lookup(k);
456
- await (p[hook] as Function).call(p, ...args);
457
- }
458
- return result;
459
- };
442
+ /**
443
+ * Hooks declared as class fields (`onJoin = () => {}`) are own properties
444
+ * that shadow the prototype wrapper, so wrap them per instance.
445
+ */
446
+ function wrapOwnHooks(room: RoomPluginHost, layout: PluginLayout): void {
447
+ for (const hook of PLUGIN_LIFECYCLE_KEYS) {
448
+ const { before, after } = layout.hooks[hook];
449
+ if (before.length === 0 && after.length === 0) { continue; }
450
+ if (!Object.hasOwn(room, hook)) { continue; }
451
+ (room as any)[hook] = composePluginHook(hook, before, after, (room as any)[hook]);
460
452
  }
461
453
  }
462
454
 
455
+ /** Run the `before` plugins, then `original`, then the `after` plugins. */
456
+ function composePluginHook(
457
+ hook: PluginLifecycleKey,
458
+ before: string[],
459
+ after: string[],
460
+ original: Function | undefined,
461
+ ) {
462
+ return async function (
463
+ this: { plugins?: Record<string, RoomPlugin>; _autoPlugins?: Record<string, RoomPlugin> },
464
+ ...args: any[]
465
+ ) {
466
+ // User plugins live on `this.plugins`, auto-deps (sentinel-keyed)
467
+ // on `this._autoPlugins` — kept separate so user types stay clean.
468
+ const lookup = (k: string): RoomPlugin =>
469
+ (k.startsWith(DEP_KEY_PREFIX) ? this._autoPlugins![k] : this.plugins![k]);
470
+ for (const k of before) {
471
+ const p = lookup(k);
472
+ await (p[hook] as Function).call(p, ...args);
473
+ }
474
+ let result: unknown;
475
+ if (original) { result = await original.apply(this, args); }
476
+ for (const k of after) {
477
+ const p = lookup(k);
478
+ await (p[hook] as Function).call(p, ...args);
479
+ }
480
+ return result;
481
+ };
482
+ }
483
+
463
484
  /** Structural shape used by `setupRoomPlugins` — avoids a Room import
464
485
  * so the dependency graph stays one-way (Room → RoomPlugin). */
465
486
  interface RoomPluginHost {
@@ -483,6 +504,10 @@ export function setupRoomPlugins(room: RoomPluginHost): void {
483
504
 
484
505
  attachRoomReference(room, plugins);
485
506
 
507
+ if (layout) {
508
+ wrapOwnHooks(room, layout);
509
+ }
510
+
486
511
  if (layout && layout.autoDeps.length > 0) {
487
512
  room._autoPlugins = instantiateAutoDeps(room, layout);
488
513
  }