@colyseus/core 0.18.18 → 0.18.20

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
@@ -451,13 +451,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
451
451
  .catch((e) => debugAndPrintError(`onDispose error: ${(e && e.stack || e.message || e || 'promise rejected')} (roomId: ${this.roomId})`))
452
452
  .finally(() => this._events.emit('disconnect'));
453
453
  });
454
-
455
- /**
456
- * If `onUncaughtException` is defined, it will automatically catch exceptions
457
- */
458
- if (this.onUncaughtException !== undefined) {
459
- this.#registerUncaughtExceptionHandlers();
460
- }
461
454
  }
462
455
 
463
456
  /**
@@ -592,6 +585,12 @@ export class Room<T extends RoomOptions = RoomOptions> {
592
585
  setupRoomPlugins(this);
593
586
  }
594
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
+
595
594
  // Bind messages to the room
596
595
  if (this.messages !== undefined) {
597
596
 
@@ -2487,7 +2486,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
2487
2486
  this.onCreate = wrapTryCatch(this.onCreate.bind(this), onUncaughtException, OnCreateException, 'onCreate', true);
2488
2487
  }
2489
2488
 
2490
- if (this.onAuth !== undefined) {
2489
+ // _onJoin skips onAuth only while it's still Room's default
2490
+ if (this.onAuth !== Room.prototype.onAuth) {
2491
2491
  this.onAuth = wrapTryCatch(this.onAuth.bind(this), onUncaughtException, OnAuthException, 'onAuth', true);
2492
2492
  }
2493
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
  }
package/src/Transport.ts CHANGED
@@ -252,7 +252,18 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
252
252
  reconnectionToken: string;
253
253
 
254
254
  // TODO: move these to ClientPrivate
255
+ /**
256
+ * Send raw bytes over the transport's reliable channel.
257
+ *
258
+ * The transport owns `data` from this call on: it may write it later (a
259
+ * socket with backpressure, permessage-deflate), so callers never mutate it
260
+ * afterwards. The same `data` may be handed to several clients.
261
+ */
255
262
  raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
263
+ /**
264
+ * {@link raw}, or held until the client finishes joining (or until after the
265
+ * next patch, with `afterNextPatch`). Owns `data` the same way {@link raw} does.
266
+ */
256
267
  enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions): void;
257
268
 
258
269
  /**
@@ -262,6 +273,10 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
262
273
  * Absent on transports with no datagram channel (every WebSocket transport).
263
274
  * Its presence IS the capability check — callers feature-detect rather than
264
275
  * reading a separate flag, and skip clients that can't receive.
276
+ *
277
+ * Unlike {@link raw}, `data` is only valid during this call: the caller
278
+ * reuses its bytes afterwards, so a transport that sends it later copies it
279
+ * first.
265
280
  */
266
281
  rawUnreliable?(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
267
282
 
@@ -81,6 +81,18 @@ function sendClientFrame(client: Client, encodedBody: Uint8Array, timing?: Patch
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * Detach an untimed patch from the encoder's shared buffer: the transport may
86
+ * still hold it when the next encode reuses that buffer (see {@link Client.raw}).
87
+ * Timed frames skip this — {@link buildTimedFrame} already copies per client.
88
+ */
89
+ function ownedPatch(encoded: Uint8Array, timing?: PatchTimingContext): Uint8Array {
90
+ return timing ? encoded : Buffer.from(encoded);
91
+ }
92
+
93
+ /** Empty quiet-tick patch body; sent only timed, so sharing it is safe. */
94
+ const HEARTBEAT_BODY = new Uint8Array([Protocol.ROOM_STATE_PATCH]);
95
+
84
96
  /**
85
97
  * Size of the {@link ProtocolModifier.UNRELIABLE} header that follows the
86
98
  * protocol byte: `[uint16 seq]`. See {@link ProtocolModifier.UNRELIABLE} for
@@ -126,10 +138,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
126
138
  // flag to avoid re-encoding full state if no changes were made
127
139
  protected needFullEncode: boolean = true;
128
140
 
129
- // TODO: make this optional. allocating a new buffer for each room may not be always necessary.
130
- protected fullEncodeBuffer: Uint8Array = new Uint8Array(Encoder.BUFFER_SIZE);
131
141
  protected fullEncodeCache: Uint8Array;
132
- protected sharedOffsetCache: Iterator = { offset: 0 };
133
142
 
134
143
  protected encodedViews: Map<StateView | typeof SHARED_VIEW, Uint8Array>;
135
144
 
@@ -150,8 +159,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
150
159
  this.hasFilters = this.encoder.context.hasFilters;
151
160
  this.hasUnreliableFields = stateHasUnreliableFields(this.encoder.context);
152
161
 
153
- // cache ROOM_STATE byte as part of the encoded buffer
154
- this.fullEncodeBuffer[0] = Protocol.ROOM_STATE;
162
+ this.needFullEncode = true; // the cached full state belongs to the previous encoder
155
163
 
156
164
  if (this.hasFilters) {
157
165
  this.encodedViews = new Map();
@@ -166,19 +174,17 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
166
174
 
167
175
  public getFullState(client?: Client, timing?: PatchTimingContext) {
168
176
  if (this.needFullEncode || this.encoder.root.changes.next !== undefined) {
169
- this.sharedOffsetCache = { offset: 1 };
170
- this.fullEncodeCache = this.encoder.encodeAll(this.sharedOffsetCache, this.fullEncodeBuffer);
177
+ this.encoder.sharedBuffer[0] = Protocol.ROOM_STATE;
178
+ // copy: the next encode reuses the shared buffer (slice(): a pooled Buffer would pin 8KB)
179
+ this.fullEncodeCache = this.encoder.encodeAll({ offset: 1 }).slice();
171
180
  this.needFullEncode = false;
172
181
  }
173
182
 
174
- const body = (this.hasFilters && client?.view)
175
- ? this.encoder.encodeAllView(
176
- client.view,
177
- this.sharedOffsetCache.offset,
178
- { ...this.sharedOffsetCache },
179
- this.fullEncodeBuffer
180
- )
181
- : this.fullEncodeCache;
183
+ let body = this.fullEncodeCache;
184
+ if (this.hasFilters && client?.view) {
185
+ this.encoder.sharedBuffer.set(body); // patches since the full encode may have overwritten it
186
+ body = this.encoder.encodeAllView(client.view, body.byteLength, { offset: body.byteLength });
187
+ }
182
188
 
183
189
  if (timing && client) {
184
190
  return buildTimedFrame(body, timing.sNow, clientProcessedInputSeq(client));
@@ -232,11 +238,10 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
232
238
  // body — 1 byte protocol + 8 bytes prefix.
233
239
  // No-op for rooms without `defineInput()` (timing is undefined).
234
240
  if (timing) {
235
- const heartbeatBody = Buffer.from([Protocol.ROOM_STATE_PATCH]);
236
241
  for (let i = 0; i < clients.length; i++) {
237
242
  const client = clients[i];
238
243
  if (client.state !== ClientState.JOINED) continue;
239
- sendClientFrame(client, heartbeatBody, timing);
244
+ sendClientFrame(client, HEARTBEAT_BODY, timing);
240
245
  }
241
246
  }
242
247
 
@@ -262,6 +267,8 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
262
267
  const encodedChanges = this.encoder.encode(it);
263
268
 
264
269
  if (!this.hasFilters) {
270
+ const frame = ownedPatch(encodedChanges, timing);
271
+
265
272
  while (numClients--) {
266
273
  const client = clients[numClients];
267
274
 
@@ -272,7 +279,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
272
279
  continue;
273
280
  }
274
281
 
275
- sendClientFrame(client, encodedChanges, timing);
282
+ sendClientFrame(client, frame, timing);
276
283
  }
277
284
 
278
285
  } else {
@@ -297,7 +304,7 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
297
304
  // allow to pass the same encoded view for multiple clients
298
305
  if (encodedView === undefined) {
299
306
  encodedView = (view === SHARED_VIEW)
300
- ? encodedChanges
307
+ ? ownedPatch(encodedChanges, timing) // encodeView already returns a copy
301
308
  : this.encoder.encodeView(client.view, sharedOffset, it);
302
309
  this.encodedViews.set(view, encodedView);
303
310
  }