@colyseus/core 0.18.1 → 0.18.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 (38) hide show
  1. package/build/Room.cjs.map +1 -1
  2. package/build/Room.mjs.map +1 -1
  3. package/build/Transport.cjs.map +2 -2
  4. package/build/Transport.d.ts +7 -6
  5. package/build/Transport.mjs.map +2 -2
  6. package/build/errors/RoomExceptions.cjs.map +1 -1
  7. package/build/errors/RoomExceptions.d.ts +14 -14
  8. package/build/errors/RoomExceptions.mjs.map +1 -1
  9. package/build/input/InputBuffer.cjs +26 -0
  10. package/build/input/InputBuffer.cjs.map +2 -2
  11. package/build/input/InputBuffer.d.ts +10 -0
  12. package/build/input/InputBuffer.mjs +25 -0
  13. package/build/input/InputBuffer.mjs.map +2 -2
  14. package/build/input/RoomInput.cjs +1 -0
  15. package/build/input/RoomInput.cjs.map +2 -2
  16. package/build/input/RoomInput.mjs +2 -0
  17. package/build/input/RoomInput.mjs.map +2 -2
  18. package/build/presence/LocalPresence.cjs.map +2 -2
  19. package/build/presence/LocalPresence.d.ts +1 -1
  20. package/build/presence/LocalPresence.mjs.map +2 -2
  21. package/build/router/index.cjs +13 -6
  22. package/build/router/index.cjs.map +2 -2
  23. package/build/router/index.mjs +13 -6
  24. package/build/router/index.mjs.map +2 -2
  25. package/build/serializer/SchemaSerializer.cjs.map +1 -1
  26. package/build/serializer/SchemaSerializer.mjs.map +1 -1
  27. package/build/serializer/Serializer.cjs.map +1 -1
  28. package/build/serializer/Serializer.d.ts +4 -4
  29. package/package.json +8 -5
  30. package/src/Room.ts +1 -1
  31. package/src/Transport.ts +7 -6
  32. package/src/errors/RoomExceptions.ts +18 -18
  33. package/src/input/InputBuffer.ts +32 -0
  34. package/src/input/RoomInput.ts +6 -1
  35. package/src/presence/LocalPresence.ts +1 -1
  36. package/src/router/index.ts +26 -13
  37. package/src/serializer/SchemaSerializer.ts +4 -4
  38. package/src/serializer/Serializer.ts +4 -4
@@ -27,11 +27,11 @@ export type RoomException<R extends Room = Room> =
27
27
  TimedEventException;
28
28
 
29
29
  export class OnCreateException<R extends Room = Room> extends Error {
30
- options: Parameters<R['onCreate']>[0];
30
+ options: Parameters<NonNullable<R['onCreate']>>[0];
31
31
  constructor(
32
32
  cause: Error,
33
33
  message: string,
34
- options: Parameters<R['onCreate']>[0],
34
+ options: Parameters<NonNullable<R['onCreate']>>[0],
35
35
  ) {
36
36
  super(message, { cause });
37
37
  this.name = 'OnCreateException';
@@ -56,15 +56,15 @@ export class OnAuthException<R extends Room = Room> extends Error {
56
56
  }
57
57
 
58
58
  export class OnJoinException<R extends Room = Room> extends Error {
59
- client: Parameters<R['onJoin']>[0];
60
- options: Parameters<R['onJoin']>[1];
61
- auth: Parameters<R['onJoin']>[2];
59
+ client: Parameters<NonNullable<R['onJoin']>>[0];
60
+ options: Parameters<NonNullable<R['onJoin']>>[1];
61
+ auth: Parameters<NonNullable<R['onJoin']>>[2];
62
62
  constructor(
63
63
  cause: Error,
64
64
  message: string,
65
- client: Parameters<R['onJoin']>[0],
66
- options: Parameters<R['onJoin']>[1],
67
- auth: Parameters<R['onJoin']>[2],
65
+ client: Parameters<NonNullable<R['onJoin']>>[0],
66
+ options: Parameters<NonNullable<R['onJoin']>>[1],
67
+ auth: Parameters<NonNullable<R['onJoin']>>[2],
68
68
  ) {
69
69
  super(message, { cause });
70
70
  this.name = 'OnJoinException';
@@ -75,13 +75,13 @@ export class OnJoinException<R extends Room = Room> extends Error {
75
75
  }
76
76
 
77
77
  export class OnLeaveException<R extends Room = Room> extends Error {
78
- client: Parameters<R['onLeave']>[0];
79
- consented: Parameters<R['onLeave']>[1];
78
+ client: Parameters<NonNullable<R['onLeave']>>[0];
79
+ consented: Parameters<NonNullable<R['onLeave']>>[1];
80
80
  constructor(
81
81
  cause: Error,
82
82
  message: string,
83
- client: Parameters<R['onLeave']>[0],
84
- consented: Parameters<R['onLeave']>[1],
83
+ client: Parameters<NonNullable<R['onLeave']>>[0],
84
+ consented: Parameters<NonNullable<R['onLeave']>>[1],
85
85
  ) {
86
86
  super(message, { cause });
87
87
  this.name = 'OnLeaveException';
@@ -91,13 +91,13 @@ export class OnLeaveException<R extends Room = Room> extends Error {
91
91
  }
92
92
 
93
93
  export class OnDropException<R extends Room = Room> extends Error {
94
- client: Parameters<R['onDrop']>[0];
95
- code: Parameters<R['onDrop']>[1];
94
+ client: Parameters<NonNullable<R['onDrop']>>[0];
95
+ code: Parameters<NonNullable<R['onDrop']>>[1];
96
96
  constructor(
97
97
  cause: Error,
98
98
  message: string,
99
- client: Parameters<R['onDrop']>[0],
100
- code: Parameters<R['onDrop']>[1],
99
+ client: Parameters<NonNullable<R['onDrop']>>[0],
100
+ code: Parameters<NonNullable<R['onDrop']>>[1],
101
101
  ) {
102
102
  super(message, { cause });
103
103
  this.name = 'OnDropException';
@@ -107,11 +107,11 @@ export class OnDropException<R extends Room = Room> extends Error {
107
107
  }
108
108
 
109
109
  export class OnReconnectException<R extends Room = Room> extends Error {
110
- client: Parameters<R['onReconnect']>[0];
110
+ client: Parameters<NonNullable<R['onReconnect']>>[0];
111
111
  constructor(
112
112
  cause: Error,
113
113
  message: string,
114
- client: Parameters<R['onReconnect']>[0],
114
+ client: Parameters<NonNullable<R['onReconnect']>>[0],
115
115
  ) {
116
116
  super(message, { cause });
117
117
  this.name = 'OnReconnectException';
@@ -38,6 +38,10 @@ export function compileSanitizer<I>(spec: SanitizeInput<I>): (input: I) => void
38
38
  const inst = input as Record<string, number>;
39
39
  for (let i = 0; i < names.length; i++) {
40
40
  const v = inst[names[i]];
41
+ // Never-received field: no input to sanitize — clamping would INVENT
42
+ // one (delta-encoding clients legitimately never transmit fields they
43
+ // never assigned; the range floor turned "no input" into "held stick").
44
+ if (v === undefined) { continue; }
41
45
  // NaN-safe: NaN fails both comparisons → clamp floor.
42
46
  inst[names[i]] = v >= mins[i] ? (v <= maxs[i] ? v : maxs[i]) : mins[i];
43
47
  }
@@ -59,6 +63,33 @@ function fieldNamesOf(ctor: new () => any): string[] {
59
63
  return names;
60
64
  }
61
65
 
66
+ /**
67
+ * @internal Seed every declared field that has no construction default with
68
+ * its wire-neutral zero value (0 / false / ""; quantized: 0 clamped into the
69
+ * descriptor's range). The input contract is "the server holds the last
70
+ * received value" — before anything is received, that value is the ZERO of
71
+ * the field's type, never `undefined`: a sim would NaN-propagate it and a
72
+ * range sanitizer would clamp it into a phantom input. Clients only transmit
73
+ * fields they assign, so an untouched field stays absent on the wire forever.
74
+ */
75
+ export function seedInputZeroValues(instance: any, ctor: new () => any): void {
76
+ const md = (ctor as any)[$METADATA] as
77
+ Record<number, { name?: string, type?: unknown }> | undefined;
78
+ if (!md) { return; }
79
+ for (let i = 0; ; i++) {
80
+ const f = md[i];
81
+ if (!f || typeof f.name !== "string") break;
82
+ if (instance[f.name] !== undefined) { continue; } // .default() wins
83
+ const type = f.type;
84
+ if (typeof type === "string") {
85
+ instance[f.name] = type === "string" ? "" : type === "boolean" ? false : 0;
86
+ } else if (type !== null && typeof type === "object" && "quantized" in (type as object)) {
87
+ const q = (type as { quantized: { min: number, max: number } }).quantized;
88
+ instance[f.name] = q.min > 0 ? q.min : q.max < 0 ? q.max : 0;
89
+ }
90
+ }
91
+ }
92
+
62
93
  /** Reclaim the consumed prefix once the read cursor passes this many items —
63
94
  * bounds the parallel arrays' slack to O(THRESHOLD) between compactions while
64
95
  * keeping per-consume work O(1) (no `shift()` re-index on every input). */
@@ -184,6 +215,7 @@ export class InputBufferImpl<I = any> {
184
215
  if (this._idle === undefined) {
185
216
  // Lazy mint (cold): the fresh instance doubles as the defaults source.
186
217
  this._idle = new this._ctor!();
218
+ seedInputZeroValues(this._idle, this._ctor!);
187
219
  this._fieldNames = fieldNamesOf(this._ctor!);
188
220
  const fresh = this._idle as Record<string, unknown>;
189
221
  this._defaults = this._fieldNames.map((n) => fresh[n]);
@@ -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, validateSubSteps,
7
+ compileSanitizer, seedInputZeroValues, validateSubSteps,
8
8
  } from './InputBuffer.ts';
9
9
  import type {
10
10
  InputAccessor, InputAPI, NormalizedInputOptions,
@@ -152,6 +152,11 @@ export class RoomInput {
152
152
  * via `bufferMaxSize > 0`, for rollback/lockstep), and the accessor. */
153
153
  allocate(client: Client & ClientPrivate): void {
154
154
  client._input = new this.options.ctor();
155
+ // Wire-neutral zero values for fields with no construction default: a
156
+ // client only transmits fields it assigns, and `undefined` here would
157
+ // NaN-propagate into the sim (or get floor-clamped by a range sanitizer
158
+ // into a PHANTOM held input).
159
+ seedInputZeroValues(client._input, this.options.ctor);
155
160
  client._inputDecoder = new InputDecoder(client._input);
156
161
  client._reckonBaseline = 0; // mirrors the SDK's delta-coded stamp baseline (reset together on (re)connect)
157
162
  const maxSize = this.options.bufferMaxSize;
@@ -8,7 +8,7 @@ import { hasDevModeCache, isDevMode, getDevModeCache, writeDevModeCache } from '
8
8
  type Callback = (...args: any[]) => void;
9
9
 
10
10
  export class LocalPresence implements Presence {
11
- public subscriptions = new EventEmitter();
11
+ public subscriptions: EventEmitter = new EventEmitter();
12
12
 
13
13
  public data: {[roomName: string]: string[]} = {};
14
14
  public hash: {[roomName: string]: {[key: string]: string}} = {};
@@ -41,7 +41,7 @@ export function bindRouterToTransport(transport: Transport, router: Router, useE
41
41
  // add default "/" route, if not provided.
42
42
  const hasRootRoute = (
43
43
  // check if express app has a root route
44
- (expressApp && expressRootRoute(expressApp) !== undefined) ||
44
+ (expressApp && hasExpressRootRoute(expressApp)) ||
45
45
 
46
46
  // check if router has a root route
47
47
  Object.values(router.endpoints).some(endpoint => endpoint.path === "/")
@@ -105,20 +105,33 @@ export function bindRouterToTransport(transport: Transport, router: Router, useE
105
105
  });
106
106
  }
107
107
 
108
- function expressRootRoute(expressApp: express.Application) {
109
- //
110
- // express v5 uses `app.router`, express v4 uses `app._router`
111
- // check for `app._router` first, then `app.router`
112
- //
113
- // (express v4 will show a warning if `app.router` is used)
114
- //
115
- const stack = (expressApp as any)?._router?.stack ?? (expressApp as any)?.router?.stack;
116
-
117
- if (!stack) {
118
- return false;
108
+ /**
109
+ * Whether the express app already handles the root route, in which case
110
+ * Colyseus must not register its own default "/" endpoint over it.
111
+ */
112
+ function hasExpressRootRoute(expressApp: express.Application) {
113
+ return expressRouterStack(expressApp).some((layer: any) =>
114
+ layer.match('/') && !['query', 'expressInit'].includes(layer.name));
115
+ }
116
+
117
+ /**
118
+ * The app's router stack, or an empty stack if it has no routes yet.
119
+ *
120
+ * express v5 exposes the router as `app.router`; v4 exposes it as `app._router`
121
+ * and only creates it once the first route/middleware is registered.
122
+ */
123
+ function expressRouterStack(expressApp: express.Application): any[] {
124
+ const app = expressApp as any;
125
+
126
+ if (app?._router?.stack) {
127
+ return app._router.stack;
119
128
  }
120
129
 
121
- return stack.find((layer: any) => layer.match('/') && !['query', 'expressInit'].includes(layer.name));
130
+ try {
131
+ return app?.router?.stack ?? [];
132
+ } catch (e) {
133
+ return []; // express v4 throws on `app.router` — no routes registered
134
+ }
122
135
  }
123
136
 
124
137
  export function createRouter<
@@ -174,10 +174,10 @@ export class SchemaSerializer<T extends Schema> implements Serializer<T> {
174
174
  }
175
175
  }
176
176
 
177
- // Heartbeat: clients that advertised CLIENT_TIMING still need
178
- // periodic timing samples to keep their clock offset / RTT estimate
179
- // fresh during quiet ticks. Emit a TIMED-prefixed ROOM_STATE_PATCH
180
- // with an empty schema body — 1 byte protocol + 12 bytes prefix.
177
+ // Heartbeat: `defineInput()` rooms still need periodic timing samples
178
+ // to keep client clock offset / RTT estimates fresh during quiet
179
+ // ticks. Emit a TIMED-prefixed ROOM_STATE_PATCH with an empty schema
180
+ // body — 1 byte protocol + 8 bytes prefix.
181
181
  // No-op for rooms without `defineInput()` (timing is undefined).
182
182
  if (timing) {
183
183
  const heartbeatBody = Buffer.from([Protocol.ROOM_STATE_PATCH]);
@@ -2,12 +2,12 @@ import type { Client } from '../Transport.ts';
2
2
 
3
3
  /**
4
4
  * Per-tick timing context, populated by the Room when
5
- * {@link Room.defineInput} was called. The serializer uses it to emit a
6
- * {@link ProtocolModifier.TIMED} prefix for clients that advertised
7
- * the {@link HandshakeSection.CLIENT_TIMING} capability.
5
+ * {@link Room.defineInput} was called. Its presence makes the serializer emit
6
+ * a {@link ProtocolModifier.TIMED} prefix on every state/patch frame — the
7
+ * gate is per-room (defineInput), not a per-client capability.
8
8
  */
9
9
  export interface PatchTimingContext {
10
- /** `performance.now()` at the moment the patch is being applied. */
10
+ /** Server clock as ms since room start (`clock.elapsedTime`) at patch time. */
11
11
  sNow: number;
12
12
  }
13
13