@colyseus/core 0.18.24 → 0.18.26

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.
@@ -7,8 +7,9 @@ import { createNanoEvents } from './utils/nanoevents.ts';
7
7
  import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
8
8
  import { isDevMode } from './utils/DevMode.ts';
9
9
  import { debugMessage, debugAndPrintError } from './Debug.ts';
10
+ import { logger } from './Logger.ts';
10
11
  import { OnMessageException } from './errors/RoomExceptions.ts';
11
- import type { Client, ClientPrivate } from './Transport.ts';
12
+ import { ClientState, type Client, type ClientPrivate } from './Transport.ts';
12
13
  import type { Room } from './Room.ts';
13
14
 
14
15
  /** Normalize a thrown value into the `{ name, message, code? }` shape echoed in a
@@ -74,7 +75,7 @@ const SEND_CONTEXT: MessageContext = Object.freeze({
74
75
  * ROOM_DATA_BYTES). Room's `_onMessage` keeps the protocol/lifecycle frames
75
76
  * (JOIN/PING/LEAVE/input) and calls one of these per frame, preserving the
76
77
  * original dispatch order. Reads back into its Room only for `onUncaughtException`
77
- * (handler wrapping) and `roomId`/`roomName` (logging).
78
+ * (handler wrapping), `onUnhandledMessage` and `roomId`/`roomName` (logging).
78
79
  *
79
80
  * @internal
80
81
  */
@@ -263,6 +264,10 @@ export class RoomMessages {
263
264
  const handler = this.events.events[messageType as string]?.[0];
264
265
 
265
266
  if (handler === undefined) {
267
+ // no warning without the hook: the client already gets an explicit error
268
+ if (this.room.onUnhandledMessage !== undefined) {
269
+ this.#reportUnhandled(client, messageType, message);
270
+ }
266
271
  this.#replyToRequest(client, requestId, ResponseStatus.ERROR, {
267
272
  name: "no_handler",
268
273
  message: `room "${this.room.roomName}" has no onMessage("${messageType}") handler to answer this request.`,
@@ -351,13 +356,45 @@ export class RoomMessages {
351
356
  }
352
357
 
353
358
  /** No handler for the type: error the client in dev, drop it in production. */
354
- #noHandler(client: Client, messageType: string | number, _message: unknown): void {
359
+ #noHandler(client: Client, messageType: string | number, message: unknown): void {
355
360
  const errorMessage = `room onMessage for "${messageType}" not registered.`;
356
- debugMessage(`${errorMessage} (roomId: ${this.room.roomId})`);
361
+
362
+ if (this.room.onUnhandledMessage !== undefined) {
363
+ this.#reportUnhandled(client, messageType, message);
364
+
365
+ } else {
366
+ logger.warn(`room "${this.room.roomName}" (roomId: ${this.room.roomId}): client '${client.sessionId}' sent "${messageType}", which has no onMessage() handler. ${(isDevMode)
367
+ ? "Replied with an INVALID_PAYLOAD error (outside devMode, the client is disconnected)."
368
+ : "Disconnecting the client."}`);
369
+ }
370
+
357
371
  if (isDevMode) {
358
372
  client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
359
373
  } else {
374
+ // frames already received still arrive after leave(); Room#_onMessage skips them
375
+ client.state = ClientState.LEAVING;
360
376
  client.leave(CloseCode.WITH_ERROR, errorMessage);
361
377
  }
362
378
  }
379
+
380
+ /** Call `room.onUnhandledMessage`. A throw or rejection goes to
381
+ * `onUncaughtException` when set, or is logged; it never stops the default. */
382
+ #reportUnhandled(client: Client, messageType: string | number, message: unknown): void {
383
+ const report = (e: any) => {
384
+ if (this.room.onUncaughtException !== undefined) {
385
+ this.room.onUncaughtException(new OnMessageException<Room<any>, any>(e, e?.message, client, message, messageType), 'onMessage');
386
+ } else {
387
+ debugAndPrintError(e);
388
+ }
389
+ };
390
+
391
+ try {
392
+ const result = this.room.onUnhandledMessage!(client, messageType, message) as any;
393
+ if (typeof result?.catch === 'function') {
394
+ result.catch(report);
395
+ }
396
+ } catch (e: any) {
397
+ report(e);
398
+ }
399
+ }
363
400
  }
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export {
20
20
  // Core classes
21
21
  export { Server, defineRoom, defineServer, registerRoomDefinitions, unregisterRoomDefinitions, applySimulatedLatency, parseLatencyEnv, type RoomDefinitions, type ServerOptions, type SDKTypes } from './Server.ts';
22
22
  export { Room, RoomInternalState, validate, type RoomOptions, type DefineInputOptions, type SimulationCallback, type FixedTimestepCallback, type StepContext, type MessageHandlerWithFormat, type Messages, type ExtractRoomState, type ExtractRoomMetadata, type ExtractRoomClient } from './Room.ts';
23
- export { InputBufferImpl, compileSanitizer } from './input/InputBuffer.ts';
23
+ export { InputBufferImpl, compileSanitizer, compileSnapshot } from './input/InputBuffer.ts';
24
24
  export { type InputAccessor, type InputAPI, type NormalizedInputOptions, type ConsumeOptions, type IdleInput, type IdleContext, type SanitizeInput, type NumericFieldsOf } from './input/types.ts';
25
25
  export { Rewind, RewindView, type RewindOptions, type RewindMode } from './Rewind.ts';
26
26
  export {
@@ -1,3 +1,5 @@
1
+ import { $values } from '@colyseus/schema';
2
+
1
3
  import type { ClientPrivate } from '../Transport.ts';
2
4
  import { $METADATA } from '../utils/Utils.ts';
3
5
 
@@ -43,7 +45,9 @@ export function compileSanitizer<I>(spec: SanitizeInput<I>): (input: I) => void
43
45
  // never assigned; the range floor turned "no input" into "held stick").
44
46
  if (v === undefined) { continue; }
45
47
  // NaN-safe: NaN fails both comparisons → clamp floor.
46
- inst[names[i]] = v >= mins[i] ? (v <= maxs[i] ? v : maxs[i]) : mins[i];
48
+ const clamped = v >= mins[i] ? (v <= maxs[i] ? v : maxs[i]) : mins[i];
49
+ // Honest frames clamp nothing: a keyed write through the setter costs more than the walk.
50
+ if (clamped !== v) { inst[names[i]] = clamped; }
47
51
  }
48
52
  };
49
53
  }
@@ -63,6 +67,59 @@ function fieldNamesOf(ctor: new () => any): string[] {
63
67
  return names;
64
68
  }
65
69
 
70
+ /** One per input schema, so every room's frames share a hidden class. */
71
+ const _snapshots = new WeakMap<new () => any, (input: any) => any>();
72
+
73
+ /**
74
+ * @internal Compile how a decoded input is copied into the buffer. `clone()`
75
+ * builds a Schema and its ChangeTree per input, for a frame that's never
76
+ * encoded, so a schema of plain values gets a lighter frame: its own array of
77
+ * values behind the ctor's prototype. Methods, getters, `instanceof`, `clone()`
78
+ * and `toJSON()` all hold, and a field write lands on the copy, untracked. A
79
+ * nested schema or collection keeps `clone()`, whose copy is deep.
80
+ *
81
+ * Fields are read by name, as `clone()` reads them, never by copying `$values`:
82
+ * native class fields (`useDefineForClassFields`) shadow the schema's accessors,
83
+ * so the decoder's values sit on the instance and `$values` stays empty.
84
+ */
85
+ export function compileSnapshot<I>(ctor: new () => I): (input: I) => I {
86
+ let snapshot = _snapshots.get(ctor);
87
+ if (snapshot !== undefined) { return snapshot; }
88
+
89
+ const md = (ctor as any)[$METADATA] as Record<number, { type?: unknown }> | undefined;
90
+ const names = fieldNamesOf(ctor);
91
+ const plain = md !== undefined && names.every((_, i) => {
92
+ const type = md[i].type;
93
+ return typeof type === "string" || (type !== null && typeof type === "object" && "quantized" in type);
94
+ });
95
+
96
+ if (plain) {
97
+ const fields: PropertyDescriptorMap = {};
98
+ names.forEach((name, i) => {
99
+ fields[name] = {
100
+ get(this: any) { return this[$values][i]; },
101
+ set(this: any, value: unknown) { this[$values][i] = value; },
102
+ enumerable: true,
103
+ configurable: true,
104
+ };
105
+ });
106
+ // A function, not a class: `constructor` must stay the schema's (clone/toJSON read its metadata).
107
+ function Frame(this: any, values: unknown[]) { this[$values] = values; }
108
+ Frame.prototype = Object.create(ctor.prototype, fields);
109
+ const count = names.length;
110
+ snapshot = (input) => {
111
+ const values = new Array(count);
112
+ for (let i = 0; i < count; i++) { values[i] = input[names[i]]; }
113
+ return new (Frame as any)(values);
114
+ };
115
+ } else {
116
+ snapshot = (input) => input.clone();
117
+ }
118
+
119
+ _snapshots.set(ctor, snapshot!);
120
+ return snapshot!;
121
+ }
122
+
66
123
  /**
67
124
  * @internal Seed every declared field that has no construction default with
68
125
  * its wire-neutral zero value (0 / false / ""; quantized: 0 clamped into the
@@ -4,7 +4,7 @@ import { HandshakeSection, InputFlags, ProtocolModifier } from '@colyseus/shared
4
4
 
5
5
  import {
6
6
  InputAccessorImpl, InputBufferImpl, NO_OP_INPUT_ACCESSOR,
7
- compileSanitizer, seedInputZeroValues, validateSubSteps,
7
+ compileSanitizer, compileSnapshot, seedInputZeroValues, validateSubSteps,
8
8
  } from './InputBuffer.ts';
9
9
  import type {
10
10
  InputAccessor, InputAPI, NormalizedInputOptions,
@@ -148,6 +148,7 @@ export class RoomInput {
148
148
  idle: opts?.idle,
149
149
  // Compiled once (map → dense min/max walk); applied per decoded frame.
150
150
  sanitize: opts?.sanitize !== undefined ? compileSanitizer(opts.sanitize) : undefined,
151
+ snapshot: compileSnapshot(type),
151
152
  tickRate,
152
153
  subSteps: validateSubSteps(opts?.subSteps, 'defineInput'),
153
154
  };
@@ -220,15 +221,15 @@ export class RoomInput {
220
221
 
221
222
  /**
222
223
  * Sanitize the freshly-decoded `client._input`, then (when buffering is on)
223
- * push a clone into the per-client buffer. Honors the framework seq
224
+ * push a snapshot into the per-client buffer. Honors the framework seq
224
225
  * (unreliable) / a user `inputOptions.seqField` (reliable) to dedupe the
225
226
  * redundancy ring.
226
227
  */
227
228
  capture(client: ClientPrivate, renderTime: number = 0, reckonTime: number = 0, seq?: number): void {
228
- // Sanitize before anything reads it (latest, the clone below, the idle ctx).
229
+ // Sanitize before anything reads it (latest, the snapshot below, the idle ctx).
229
230
  this.options.sanitize?.(client._input);
230
231
  const buf = client._inputBuffer;
231
- if (!buf) { return; } // no consumer registered — skip the clone allocation
232
+ if (!buf) { return; } // no consumer registered — skip the snapshot allocation
232
233
  const inst = client._input!;
233
234
  if (seq !== undefined) {
234
235
  // Unreliable: dedup the ring by the framework wire seq (user seqField is for .at() only).
@@ -241,7 +242,7 @@ export class RoomInput {
241
242
  if (typeof value === 'number' && !buf.accept(value)) { return; }
242
243
  }
243
244
  }
244
- buf.push(inst.clone() as any, renderTime, reckonTime, seq);
245
+ buf.push(this.options.snapshot(inst), renderTime, reckonTime, seq);
245
246
  }
246
247
 
247
248
  /** Decode a `ROOM_INPUT_RELIABLE` frame: an optional TIMED stamp prefix (shape
@@ -193,7 +193,7 @@ export interface NormalizedInputOptions {
193
193
  seqField?: string;
194
194
 
195
195
  /**
196
- * > 0 enables per-client buffering of cloned snapshots — required for
196
+ * > 0 enables per-client buffering of input snapshots — required for
197
197
  * `room.inputs.get(sessionId).drain() / .peek() / .at()` to return populated
198
198
  * data. Oldest drops on overflow. Set to `0` to disable (`.latest` still
199
199
  * works).
@@ -229,6 +229,12 @@ export interface NormalizedInputOptions {
229
229
  * `latest` / the buffer / the idle ctx.
230
230
  */
231
231
  sanitize?: (instance: any) => void;
232
+
233
+ /**
234
+ * COMPILED copy of a decoded frame for the buffer (see `compileSnapshot`).
235
+ * Reads and writes like the schema, without its change tracking.
236
+ */
237
+ snapshot: (instance: any) => any;
232
238
  }
233
239
 
234
240
  /**