@colyseus/core 0.18.4 → 0.18.6

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 (122) hide show
  1. package/build/Debug.cjs +1 -0
  2. package/build/Debug.cjs.map +1 -1
  3. package/build/IPC.cjs +1 -0
  4. package/build/IPC.cjs.map +1 -1
  5. package/build/Logger.cjs +1 -0
  6. package/build/Logger.cjs.map +1 -1
  7. package/build/MatchMaker.cjs +11 -3
  8. package/build/MatchMaker.cjs.map +2 -2
  9. package/build/MatchMaker.mjs +10 -3
  10. package/build/MatchMaker.mjs.map +2 -2
  11. package/build/Protocol.cjs +1 -0
  12. package/build/Protocol.cjs.map +1 -1
  13. package/build/Rewind.cjs +1 -0
  14. package/build/Rewind.cjs.map +1 -1
  15. package/build/Room.cjs +102 -0
  16. package/build/Room.cjs.map +2 -2
  17. package/build/Room.d.ts +57 -0
  18. package/build/Room.mjs +101 -0
  19. package/build/Room.mjs.map +2 -2
  20. package/build/RoomMessages.cjs +1 -0
  21. package/build/RoomMessages.cjs.map +1 -1
  22. package/build/RoomPlugin.cjs +1 -0
  23. package/build/RoomPlugin.cjs.map +1 -1
  24. package/build/Server.cjs +1 -0
  25. package/build/Server.cjs.map +1 -1
  26. package/build/Stats.cjs +1 -0
  27. package/build/Stats.cjs.map +1 -1
  28. package/build/Transport.cjs +39 -0
  29. package/build/Transport.cjs.map +2 -2
  30. package/build/Transport.d.ts +66 -1
  31. package/build/Transport.mjs +37 -1
  32. package/build/Transport.mjs.map +2 -2
  33. package/build/errors/RoomExceptions.cjs +1 -0
  34. package/build/errors/RoomExceptions.cjs.map +1 -1
  35. package/build/errors/SeatReservationError.cjs +1 -0
  36. package/build/errors/SeatReservationError.cjs.map +1 -1
  37. package/build/errors/ServerError.cjs +1 -0
  38. package/build/errors/ServerError.cjs.map +1 -1
  39. package/build/index.cjs +5 -0
  40. package/build/index.cjs.map +2 -2
  41. package/build/index.d.ts +1 -1
  42. package/build/index.mjs +3 -1
  43. package/build/index.mjs.map +2 -2
  44. package/build/input/InputBuffer.cjs +1 -0
  45. package/build/input/InputBuffer.cjs.map +1 -1
  46. package/build/input/RoomInput.cjs +64 -3
  47. package/build/input/RoomInput.cjs.map +2 -2
  48. package/build/input/RoomInput.d.ts +16 -2
  49. package/build/input/RoomInput.mjs +63 -3
  50. package/build/input/RoomInput.mjs.map +2 -2
  51. package/build/input/types.cjs +1 -0
  52. package/build/input/types.cjs.map +1 -1
  53. package/build/internal.cjs +1 -0
  54. package/build/internal.cjs.map +1 -1
  55. package/build/matchmaker/Lobby.cjs +1 -0
  56. package/build/matchmaker/Lobby.cjs.map +1 -1
  57. package/build/matchmaker/LocalDriver/LocalDriver.cjs +1 -0
  58. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +1 -1
  59. package/build/matchmaker/LocalDriver/Query.cjs +1 -0
  60. package/build/matchmaker/LocalDriver/Query.cjs.map +1 -1
  61. package/build/matchmaker/RegisteredHandler.cjs +1 -0
  62. package/build/matchmaker/RegisteredHandler.cjs.map +1 -1
  63. package/build/matchmaker/controller.cjs +1 -0
  64. package/build/matchmaker/controller.cjs.map +1 -1
  65. package/build/matchmaker/driver.cjs +1 -0
  66. package/build/matchmaker/driver.cjs.map +2 -2
  67. package/build/matchmaker/driver.d.ts +4 -2
  68. package/build/matchmaker/driver.mjs.map +1 -1
  69. package/build/presence/LocalPresence.cjs +1 -0
  70. package/build/presence/LocalPresence.cjs.map +1 -1
  71. package/build/presence/Presence.cjs +1 -0
  72. package/build/presence/Presence.cjs.map +1 -1
  73. package/build/rooms/LobbyRoom.cjs +1 -0
  74. package/build/rooms/LobbyRoom.cjs.map +1 -1
  75. package/build/rooms/QueueRoom.cjs +1 -0
  76. package/build/rooms/QueueRoom.cjs.map +1 -1
  77. package/build/rooms/RelayRoom.cjs +1 -0
  78. package/build/rooms/RelayRoom.cjs.map +1 -1
  79. package/build/router/default_routes.cjs +2 -7
  80. package/build/router/default_routes.cjs.map +2 -2
  81. package/build/router/default_routes.mjs +2 -8
  82. package/build/router/default_routes.mjs.map +2 -2
  83. package/build/router/index.cjs +48 -1
  84. package/build/router/index.cjs.map +3 -3
  85. package/build/router/index.d.ts +6 -2
  86. package/build/router/index.mjs +47 -1
  87. package/build/router/index.mjs.map +2 -2
  88. package/build/router/node.cjs +1 -0
  89. package/build/router/node.cjs.map +1 -1
  90. package/build/serializer/NoneSerializer.cjs +1 -0
  91. package/build/serializer/NoneSerializer.cjs.map +1 -1
  92. package/build/serializer/SchemaSerializer.cjs +98 -0
  93. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  94. package/build/serializer/SchemaSerializer.d.ts +25 -0
  95. package/build/serializer/SchemaSerializer.mjs +98 -1
  96. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  97. package/build/serializer/Serializer.cjs +1 -0
  98. package/build/serializer/Serializer.cjs.map +2 -2
  99. package/build/serializer/Serializer.d.ts +15 -0
  100. package/build/utils/DevMode.cjs +1 -0
  101. package/build/utils/DevMode.cjs.map +1 -1
  102. package/build/utils/Env.cjs +1 -0
  103. package/build/utils/Env.cjs.map +1 -1
  104. package/build/utils/StandardSchema.cjs +1 -0
  105. package/build/utils/StandardSchema.cjs.map +1 -1
  106. package/build/utils/UserSessionIndex.cjs +1 -0
  107. package/build/utils/UserSessionIndex.cjs.map +1 -1
  108. package/build/utils/Utils.cjs +1 -0
  109. package/build/utils/Utils.cjs.map +1 -1
  110. package/build/utils/nanoevents.cjs +1 -0
  111. package/build/utils/nanoevents.cjs.map +1 -1
  112. package/package.json +5 -5
  113. package/src/MatchMaker.ts +20 -3
  114. package/src/Room.ts +122 -0
  115. package/src/Transport.ts +132 -4
  116. package/src/index.ts +1 -1
  117. package/src/input/RoomInput.ts +76 -3
  118. package/src/matchmaker/driver.ts +4 -2
  119. package/src/router/default_routes.ts +2 -8
  120. package/src/router/index.ts +72 -3
  121. package/src/serializer/SchemaSerializer.ts +146 -1
  122. package/src/serializer/Serializer.ts +17 -0
package/src/Room.ts CHANGED
@@ -56,6 +56,9 @@ export {
56
56
 
57
57
  const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
58
58
  const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
59
+
60
+ // Shared so the unarmed `_flushUnreliable` call site stays monomorphic.
61
+ const NOOP = () => {};
59
62
  const noneSerializer = new NoneSerializer();
60
63
 
61
64
  /** Shared `enqueueRaw` options routing a frame onto `_pendingFrames` to ride the
@@ -276,6 +279,45 @@ export class Room<T extends RoomOptions = RoomOptions> {
276
279
  #_patchRate: number;
277
280
  #_patchInterval: NodeJS.Timeout;
278
281
 
282
+ /**
283
+ * Frequency to flush `@unreliable` state fields, in milliseconds.
284
+ *
285
+ * Those fields never ride a state patch — they go out over the transport's
286
+ * unreliable channel (a WebTransport datagram), so a dropped frame costs one
287
+ * stale value instead of stalling the reliable stream behind a retransmit.
288
+ * Setting this decouples them from {@link patchRate}, which is the point:
289
+ * 60Hz movement over a 20Hz structural patch.
290
+ *
291
+ * KNOWN COST of a rate faster than {@link patchRate}: an entity's ADD travels
292
+ * the reliable channel, so datagrams sent between patches can reference a
293
+ * refId the client hasn't been told about yet. Those frames are skipped by the
294
+ * client's decoder — safe (the ref graph can't desync, since `@unreliable` is
295
+ * primitives-only) but each one logs `"refId" not found`, and that entity's
296
+ * first value lands one mutation later. Measured at `patchRate/this` reports
297
+ * per mid-session spawn. Leave this unset and the flush rides
298
+ * {@link broadcastPatch}, which ships the ADD first and avoids it entirely.
299
+ *
300
+ * Requires a transport with a datagram channel — today only
301
+ * `@colyseus/h3-transport` (WebTransport), which is **experimental**. Every
302
+ * WebSocket transport lacks one, and those clients are skipped entirely (the
303
+ * room warns once), so `@unreliable` fields keep their join-time value there.
304
+ *
305
+ * @default null — flush alongside every {@link broadcastPatch}, and only when
306
+ * the state actually declares an `@unreliable` field.
307
+ */
308
+ public unreliablePatchRate: number | null = null;
309
+ #_unreliablePatchRate: number | null = null;
310
+ #_unreliablePatchInterval: NodeJS.Timeout;
311
+
312
+ /**
313
+ * The unreliable flush, called unconditionally at the end of every
314
+ * {@link broadcastPatch}. Stays {@link NOOP} unless the state actually
315
+ * declares an `@unreliable` field, so a room that never uses the channel
316
+ * pays an empty call the engine inlines away — and there is no second
317
+ * entry point that could drift from `broadcastPatch()`.
318
+ */
319
+ private _flushUnreliable: () => void = NOOP;
320
+
279
321
  /**
280
322
  * Maximum number of messages a client can send to the server per second.
281
323
  * If a client sends more messages than this, it will be disconnected.
@@ -420,6 +462,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
420
462
  this.#_state = this.state;
421
463
  this.#_autoDispose = this.autoDispose;
422
464
  this.#_patchRate = this.patchRate;
465
+ this.#_unreliablePatchRate = this.unreliablePatchRate;
423
466
  this.#_maxClients = this.maxClients;
424
467
 
425
468
  Object.defineProperties(this, {
@@ -436,6 +479,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
436
479
  }
437
480
  this._serializer.reset(newState);
438
481
  this.#_state = newState;
482
+ this._armUnreliablePatches();
439
483
  },
440
484
  },
441
485
 
@@ -479,6 +523,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
479
523
  }
480
524
  },
481
525
  },
526
+
527
+ unreliablePatchRate: {
528
+ enumerable: true,
529
+ get: () => this.#_unreliablePatchRate,
530
+ set: (milliseconds: number | null) => {
531
+ this.#_unreliablePatchRate = milliseconds;
532
+ this._armUnreliablePatches();
533
+ },
534
+ },
482
535
  });
483
536
 
484
537
  // set patch interval, now with the setter
@@ -1300,6 +1353,12 @@ export class Room<T extends RoomOptions = RoomOptions> {
1300
1353
  this._inputController !== undefined ? { sNow } : undefined,
1301
1354
  );
1302
1355
 
1356
+ // Flush `@unreliable` fields over the datagram channel, after the reliable
1357
+ // patch so a spawn's ADD is already on the wire when the datagram mutating
1358
+ // it goes out. No-op unless the state declares such a field, and skipped
1359
+ // when `unreliablePatchRate` gave the flush its own timer.
1360
+ this._flushUnreliable();
1361
+
1303
1362
  // Deliver any per-client `afterNextPatch` frames as standalone frames right
1304
1363
  // after the patch (never coalesced into it). Iterates only the clients that
1305
1364
  // staged frames this cycle (`#pendingFrameClients`), never the full list.
@@ -1321,6 +1380,64 @@ export class Room<T extends RoomOptions = RoomOptions> {
1321
1380
  return hasChanges;
1322
1381
  }
1323
1382
 
1383
+ /**
1384
+ * Encode and send the `@unreliable` state fields over each client's
1385
+ * unreliable channel. Those fields never appear in a {@link broadcastPatch}
1386
+ * frame, and clients on a transport without a datagram channel are skipped.
1387
+ *
1388
+ * Driven automatically once the state declares an `@unreliable` field — right
1389
+ * after each {@link broadcastPatch}, or on its own timer when
1390
+ * {@link unreliablePatchRate} is set. Public so a room driving
1391
+ * `broadcastPatch()` by hand can drive this by hand too.
1392
+ */
1393
+ public broadcastUnreliablePatch() {
1394
+ if (!this.state) {
1395
+ return false;
1396
+ }
1397
+ return this._serializer.applyUnreliablePatches?.(this.clients) ?? false;
1398
+ }
1399
+
1400
+ /**
1401
+ * Decide how the unreliable channel is driven — and, for the rooms that never
1402
+ * touch it, decide to not drive it at all. Runs when the state is assigned
1403
+ * (the serializer knows by then whether any `@unreliable` field exists) and
1404
+ * whenever {@link unreliablePatchRate} changes.
1405
+ *
1406
+ * A room whose state declares no `@unreliable` field leaves
1407
+ * {@link _flushUnreliable} at {@link NOOP}, so its patch tick costs exactly
1408
+ * what it did before this feature existed.
1409
+ */
1410
+ private _armUnreliablePatches() {
1411
+ if (this.#_unreliablePatchInterval) {
1412
+ clearInterval(this.#_unreliablePatchInterval);
1413
+ this.#_unreliablePatchInterval = undefined;
1414
+ }
1415
+
1416
+ const armed = this._serializer?.hasUnreliableFields === true;
1417
+ const rate = this.#_unreliablePatchRate;
1418
+ const dedicated = armed && rate !== null && rate !== 0;
1419
+
1420
+ // Default mode flushes from the patch itself rather than an independent
1421
+ // timer of the same period, which would put the datagram ahead of a spawn's
1422
+ // ADD about half the time.
1423
+ const inline = armed && !dedicated;
1424
+
1425
+ this._flushUnreliable = inline
1426
+ ? () => { this.broadcastUnreliablePatch(); }
1427
+ : NOOP;
1428
+
1429
+ if (dedicated) {
1430
+ this.#_unreliablePatchInterval = setInterval(() => this.broadcastUnreliablePatch(), rate);
1431
+
1432
+ } else if (inline && !this.#_patchRate) {
1433
+ // patchRate 0/null means no patch tick to piggyback on.
1434
+ logger.warn(
1435
+ "@colyseus/core: state has @unreliable fields but patchRate is disabled —" +
1436
+ " set `room.unreliablePatchRate` to flush them, or they will never update."
1437
+ );
1438
+ }
1439
+ }
1440
+
1324
1441
  /**
1325
1442
  * Register a message handler for a specific message type.
1326
1443
  * This method is used to handle messages sent by clients to the room.
@@ -2078,6 +2195,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
2078
2195
  this.#_patchInterval = undefined;
2079
2196
  }
2080
2197
 
2198
+ if (this.#_unreliablePatchInterval) {
2199
+ clearInterval(this.#_unreliablePatchInterval);
2200
+ this.#_unreliablePatchInterval = undefined;
2201
+ }
2202
+
2081
2203
  if (this._simulationInterval) {
2082
2204
  clearInterval(this._simulationInterval);
2083
2205
  this._simulationInterval = undefined;
package/src/Transport.ts CHANGED
@@ -8,7 +8,8 @@ import { StateView } from '@colyseus/schema';
8
8
  import type { InputDecoder } from '@colyseus/schema/input';
9
9
 
10
10
  import { EventEmitter } from 'events';
11
- import { spliceOne } from './utils/Utils.ts';
11
+ import { debugAndPrintError } from './Debug.ts';
12
+ import { getBearerToken, spliceOne } from './utils/Utils.ts';
12
13
  import { ServerError } from './errors/ServerError.ts';
13
14
 
14
15
  import type { Room } from './Room.ts';
@@ -46,12 +47,129 @@ export abstract class Transport {
46
47
  public bindRouter?(router: Router): void;
47
48
  }
48
49
 
50
+ /**
51
+ * Intercepts an incoming WebSocket upgrade request, before the handshake.
52
+ *
53
+ * Return a `Response` to answer the request instead of upgrading it. Return
54
+ * nothing to upgrade as usual. The handler may be async, and the handshake waits
55
+ * for it to resolve.
56
+ *
57
+ * `context` is the same shape `onAuth()` receives, read-only here: mutating it
58
+ * does not carry over to `onAuth()`.
59
+ *
60
+ * Not supported by `H3Transport`: WebTransport has no upgrade handshake.
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * new uWebSocketsTransport({
65
+ * beforeUpgrade: async (request, context) => {
66
+ * if (await isBanned(context.ip)) {
67
+ * return new Response(null, { status: 403 });
68
+ * }
69
+ * }
70
+ * });
71
+ * ```
72
+ */
73
+ export type BeforeUpgradeHandler = (
74
+ request: Request,
75
+ context: Readonly<AuthContext>,
76
+ ) => Response | void | Promise<Response | void>;
77
+
78
+ /**
79
+ * Invokes a `beforeUpgrade` handler, resolving with the `Response` to send
80
+ * instead of upgrading, or `undefined` to proceed with the upgrade.
81
+ *
82
+ * Every transport goes through here, so a handler written against one keeps
83
+ * working on the others. Never rejects: uWebSockets.js aborts the process on an
84
+ * upgrade handler that yields without responding, and on the other transports a
85
+ * raw socket left behind leaks a connection.
86
+ *
87
+ * @internal
88
+ */
89
+ export async function runBeforeUpgrade(
90
+ handler: BeforeUpgradeHandler,
91
+ url: string, // path, optionally including the query string
92
+ context: AuthContext,
93
+ ): Promise<Response | undefined> {
94
+ let request: Request;
95
+
96
+ try {
97
+ const host = context.headers.get('host') || 'localhost';
98
+ request = new Request(`http://${host}${url}`, { headers: context.headers });
99
+
100
+ } catch (e: any) {
101
+ // a `Host` header that isn't a valid authority fails to parse as a URL
102
+ debugAndPrintError(e);
103
+ return new Response(null, { status: 400 });
104
+ }
105
+
106
+ try {
107
+ return (await handler(request, context)) ?? undefined;
108
+
109
+ } catch (e: any) {
110
+ debugAndPrintError(e);
111
+ return new Response(null, { status: 500 });
112
+ }
113
+ }
114
+
115
+ /** Headers as the transport has them: uWebSockets.js and Node give a plain record. */
116
+ type RawHeaders = Headers | Record<string, string | undefined>;
117
+
118
+ const readHeader = (headers: RawHeaders, name: string) =>
119
+ (headers instanceof Headers) ? headers.get(name) : headers[name];
120
+
121
+ /**
122
+ * Builds the context passed to `beforeUpgrade` and `onAuth`.
123
+ *
124
+ * Every transport goes through here, so the context is identical everywhere,
125
+ * down to how the client address is resolved. `headers` is materialized on
126
+ * first read: a connection nobody inspects pays nothing for the conversion.
127
+ *
128
+ * @internal
129
+ */
130
+ export function createAuthContext(options: {
131
+ headers: RawHeaders,
132
+ token?: string | null,
133
+ remoteAddress?: string,
134
+ req?: any,
135
+ }): AuthContext {
136
+ const source = options.headers;
137
+ let headers: Headers | undefined;
138
+
139
+ return {
140
+ token: options.token ?? getBearerToken(readHeader(source, 'authorization')),
141
+ ip: resolveClientIp(source, options.remoteAddress),
142
+ req: options.req,
143
+ get headers() {
144
+ return headers ??= (source instanceof Headers)
145
+ ? source
146
+ : new Headers(source as Record<string, string>);
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * A single address, resolved the same way on every transport: `x-forwarded-for`
153
+ * carries the whole proxy chain, and only its first entry is the client.
154
+ */
155
+ function resolveClientIp(headers: RawHeaders, remoteAddress?: string): string | undefined {
156
+ // an empty header counts as absent
157
+ const firstHop = (name: string) => readHeader(headers, name)?.split(',')[0].trim() || undefined;
158
+
159
+ return (
160
+ firstHop('x-real-ip') ??
161
+ firstHop('x-forwarded-for') ??
162
+ firstHop('x-client-ip') ??
163
+ (remoteAddress || undefined)
164
+ );
165
+ }
166
+
49
167
  export type AuthContext = {
50
168
  token?: string,
169
+ /** Undefined when no proxy header carries it and the transport has no peer address. */
170
+ ip: string | undefined;
51
171
  headers: Headers,
52
- ip: string | string[];
53
- // FIXME: each transport may have its own specific properties.
54
- // "req" only applies to WebSocketTransport.
172
+ /** Only set on the HTTP matchmaking request, where it is the `Request` itself. */
55
173
  req?: any;
56
174
  };
57
175
 
@@ -136,6 +254,16 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
136
254
  raw(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
137
255
  enqueueRaw(data: Uint8Array | Buffer, options?: ISendOptions): void;
138
256
 
257
+ /**
258
+ * Send raw bytes over the transport's UNRELIABLE channel — no delivery,
259
+ * ordering, or duplication guarantee. Used for `@unreliable` state patches.
260
+ *
261
+ * Absent on transports with no datagram channel (every WebSocket transport).
262
+ * Its presence IS the capability check — callers feature-detect rather than
263
+ * reading a separate flag, and skip clients that can't receive.
264
+ */
265
+ rawUnreliable?(data: Uint8Array | Buffer, options?: ISendOptions, cb?: (err?: Error) => void): void;
266
+
139
267
  /**
140
268
  * Send a type of message to the client. Messages are encoded with MsgPack and can hold any
141
269
  * JSON-serializable data structure.
package/src/index.ts CHANGED
@@ -56,7 +56,7 @@ export * from './matchmaker/LocalDriver/LocalDriver.ts';
56
56
  export { initializeRoomCache } from './matchmaker/driver.ts';
57
57
 
58
58
  // Transport
59
- export { type Client, type ClientPrivate, type AuthContext, ClientState, ClientArray, Transport, type ISendOptions, connectClientToRoom, enqueueClientRaw } from './Transport.ts';
59
+ export { type Client, type ClientPrivate, type AuthContext, ClientState, ClientArray, Transport, type ISendOptions, type BeforeUpgradeHandler, runBeforeUpgrade, createAuthContext, connectClientToRoom, enqueueClientRaw } from './Transport.ts';
60
60
 
61
61
  // Presence
62
62
  export { type Presence } from './presence/Presence.ts';
@@ -21,6 +21,20 @@ import { debugAndPrintError } from '../Debug.ts';
21
21
  */
22
22
  const _inputReflectionCache = new WeakMap<Function, Uint8Array>();
23
23
 
24
+ /**
25
+ * Rebuild one `k`-length series of the unreliable stamp block: `newest` is the
26
+ * absolute anchor, and each wire delta walks one slot older
27
+ * (`out[i] = out[i+1] − Δ`). Mirrors the SDK's `_writeSeriesDeltas`.
28
+ */
29
+ function readSeriesDeltas(buffer: Buffer, it: Iterator, k: number, newest: number): number[] {
30
+ const out = new Array<number>(k);
31
+ out[k - 1] = newest;
32
+ for (let i = k - 2; i >= 0; i--) {
33
+ out[i] = out[i + 1] - decode.number(buffer, it);
34
+ }
35
+ return out;
36
+ }
37
+
24
38
  /**
25
39
  * Runtime behind {@link InputAPI}. A class, not a per-`define()` object literal:
26
40
  * literal (and `defineProperty`) accessors carry their closure identity in the
@@ -272,11 +286,70 @@ export class RoomInput {
272
286
  }
273
287
 
274
288
  /** Decode a `ROOM_INPUT_UNRELIABLE` redundancy ring — each slot carries its
275
- * framework seq (base seq + position) for ring dedupe, no user seqField. */
276
- decodeUnreliable(client: ClientPrivate, buffer: Buffer, _modifiers: number): void {
289
+ * framework seq (base seq + position) for ring dedupe, no user seqField.
290
+ *
291
+ * With the TIMED bit, a self-contained lag-comp stamp block precedes the ring:
292
+ *
293
+ * [varint k][uint32 newest][varint Δ]×(k−1)
294
+ * [uint16 rdNewest][varint Δrd]×(k−1) ← BOTH mode only
295
+ *
296
+ * One stamp per slot, because a packet carries k inputs sampled at k
297
+ * different instants. The anchor is absolute and the deltas never leave the
298
+ * packet, so — unlike the reliable channel's running baseline — no amount of
299
+ * loss or reordering can desync it, and an input recovered redundantly from a
300
+ * later packet still arrives with its own instant. Stamps are paired
301
+ * positionally with `decodeAll`'s oldest→newest yields; a `k` that disagrees
302
+ * with the decoded slot count means a malformed packet, so the stamps are
303
+ * dropped rather than misapplied (inputs still land, read live). */
304
+ decodeUnreliable(client: ClientPrivate, buffer: Buffer, modifiers: number): void {
277
305
  if (!client._inputDecoder) { return; }
306
+
307
+ const it: Iterator = { offset: 1 };
308
+ let stamps: number[] | undefined;
309
+ let renderDeltas: number[] | undefined;
310
+
311
+ if (modifiers & ProtocolModifier.TIMED) {
312
+ this.#resolveWireModes();
313
+ try {
314
+ const k = decode.number(buffer, it);
315
+ stamps = readSeriesDeltas(buffer, it, k, decode.uint32(buffer, it));
316
+ // BOTH mode trails the renderDelta series in the same shape, so each
317
+ // slot keeps the latency term it was actually sampled with.
318
+ if (this.#stampReckon && this.#stampRender) {
319
+ renderDeltas = readSeriesDeltas(buffer, it, k, decode.uint16(buffer, it));
320
+ }
321
+ } catch (e: any) {
322
+ debugAndPrintError(e);
323
+ return;
324
+ }
325
+ }
326
+
327
+ let i = 0;
278
328
  try {
279
- client._inputDecoder.decodeAll(buffer.subarray(1), (_inst, seq) => this.capture(client, 0, 0, seq));
329
+ const count = client._inputDecoder.decodeAll(buffer.subarray(it.offset), (_inst, seq) => {
330
+ // Positional pairing — `decodeAll` yields oldest→newest, the order the
331
+ // block was written in.
332
+ const slot = i++;
333
+ const stamp = stamps?.[slot] ?? 0;
334
+ let renderTime = 0, reckonTime = 0;
335
+ if (stamp > 0) {
336
+ if (this.#stampReckon && this.#stampRender) {
337
+ const rd = renderDeltas?.[slot] ?? 0;
338
+ reckonTime = stamp;
339
+ renderTime = stamp > rd ? stamp - rd : 0;
340
+ } else if (this.#stampReckon) {
341
+ reckonTime = stamp;
342
+ } else {
343
+ renderTime = stamp;
344
+ }
345
+ }
346
+ this.capture(client, renderTime, reckonTime, seq);
347
+ });
348
+ if (stamps !== undefined && count !== stamps.length) {
349
+ debugAndPrintError(new Error(
350
+ `@colyseus/core: unreliable input stamp block declared ${stamps.length} slots, decoded ${count}`
351
+ ));
352
+ }
280
353
  } catch (e: any) {
281
354
  debugAndPrintError(e);
282
355
  return;
@@ -188,9 +188,10 @@ export interface MatchMakerDriver {
188
188
 
189
189
  /**
190
190
  * Empty the room cache. Used for testing purposes only.
191
+ * May be asynchronous — callers must await it.
191
192
  * @internal Do not call this method yourself.
192
193
  */
193
- clear(): void;
194
+ clear(): void | Promise<void>;
194
195
 
195
196
  /**
196
197
  * Boot the room cache medium (if available).
@@ -199,6 +200,7 @@ export interface MatchMakerDriver {
199
200
 
200
201
  /**
201
202
  * Dispose the connection of the room cache medium.
203
+ * May be asynchronous — callers must await it.
202
204
  */
203
- shutdown(): void;
205
+ shutdown(): void | Promise<void>;
204
206
  }
@@ -1,8 +1,7 @@
1
1
  import { createEndpoint } from "@colyseus/better-call";
2
2
  import { createRouter } from "./index.ts";
3
3
  import * as matchMaker from "../MatchMaker.ts";
4
- import { getBearerToken } from "../utils/Utils.ts";
5
- import { getTransport } from "../Transport.ts";
4
+ import { createAuthContext, getTransport } from "../Transport.ts";
6
5
 
7
6
  export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName", { method: "POST" }, async (ctx) => {
8
7
  // do not accept matchmaking requests if already shutting down
@@ -21,12 +20,7 @@ export const postMatchmakeMethod = createEndpoint("/matchmake/:method/:roomName"
21
20
  method,
22
21
  roomName,
23
22
  clientOptions,
24
- {
25
- token: getBearerToken(ctx.request.headers.get('authorization')),
26
- headers: ctx.request.headers,
27
- ip: requestHeaders.get('x-forwarded-for') ?? requestHeaders.get('x-client-ip') ?? requestHeaders.get('x-real-ip'),
28
- req: ctx.request as any,
29
- },
23
+ createAuthContext({ headers: requestHeaders, req: ctx.request }),
30
24
  );
31
25
 
32
26
  //
@@ -1,5 +1,7 @@
1
1
  import type express from "express";
2
2
  import type { IncomingMessage, ServerResponse } from "http";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
3
5
  import { createHash, timingSafeEqual } from "node:crypto";
4
6
  import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint, createMiddleware, APIError } from "@colyseus/better-call";
5
7
  import { toNodeHandler, getRequest, setResponse } from "@colyseus/better-call/node";
@@ -250,8 +252,12 @@ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
250
252
  opts: {
251
253
  /** Key in `endpoints` whose path is a catch-all. Excluded from `specificRouter` so it doesn't eat fall-through decisions. */
252
254
  catchAllKey?: keyof E;
253
- /** Build the express middleware given the pre-built routers + node handlers. */
254
- buildMiddleware: (helpers: DualModeHelpers) => ExpressMiddleware;
255
+ /** Endpoint-path prefix baked into `endpoints` (`''`, `'/monitor'`). Used by the default middleware to rebase path mounts. */
256
+ prefix?: string;
257
+ /** SPA dist dir — the default middleware dispatches catch-all requests only for files that exist here. */
258
+ staticDir?: string;
259
+ /** Build a custom express middleware given the pre-built routers + node handlers. Omit for the default SPA-panel middleware. */
260
+ buildMiddleware?: (helpers: DualModeHelpers) => ExpressMiddleware;
255
261
  },
256
262
  ): ExpressMiddleware & E {
257
263
  const fullRouter = createRouter(endpoints);
@@ -265,8 +271,71 @@ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
265
271
  const specificRouter = createRouter(specificEndpoints as E);
266
272
  const specificHandler = toNodeHandler(specificRouter.handler) as NodeHandler;
267
273
 
268
- const middleware = opts.buildMiddleware({
274
+ const buildMiddleware = opts.buildMiddleware ?? panelMiddleware(opts.prefix ?? '', opts.staticDir);
275
+ const middleware = buildMiddleware({
269
276
  specificRouter, specificHandler, fullRouter, fullHandler,
270
277
  });
271
278
  return Object.assign(middleware, endpoints) as ExpressMiddleware & E;
272
279
  }
280
+
281
+ /**
282
+ * Default express middleware for a single-prefix SPA panel (monitor,
283
+ * playground). Express and the endpoint map use different coordinates: a path
284
+ * mount strips its prefix into `req.baseUrl`, while the endpoints keep the
285
+ * configured `prefix` baked into their paths. This rebases every request onto
286
+ * the endpoint namespace, so the panel works at any mount path — `prefix` only
287
+ * matters in router mode and at root mounts.
288
+ */
289
+ function panelMiddleware(prefix: string, staticDir?: string) {
290
+ const staticRoot = staticDir && path.resolve(staticDir);
291
+ return ({ specificRouter, specificHandler, fullHandler }: DualModeHelpers): ExpressMiddleware =>
292
+ (req, res, next) => {
293
+ const r = req as IncomingMessage & { baseUrl?: string; originalUrl?: string };
294
+ const raw = r.url ?? '';
295
+ const q = raw.indexOf('?');
296
+ const url = q < 0 ? raw : raw.slice(0, q);
297
+ const query = q < 0 ? '' : raw.slice(q);
298
+
299
+ // Path mounts arrive stripped (`req.url` is mount-relative); root and
300
+ // pathless mounts arrive unstripped and already match the endpoints.
301
+ const dispatchPath = r.baseUrl ? prefix + url : url;
302
+
303
+ // Canonicalize the index: the SPA references its assets relatively, so
304
+ // they only resolve from the trailing-slash URL. The browser's address
305
+ // is originalUrl — 302 (not 301: browsers cache 301s across remounts).
306
+ if (dispatchPath === prefix || dispatchPath === `${prefix}/`) {
307
+ const original = (r.originalUrl ?? raw).split('?')[0]!;
308
+ if (r.method === 'GET' && !original.endsWith('/')) {
309
+ res.writeHead(302, { location: `${original}/${query}` });
310
+ res.end();
311
+ return;
312
+ }
313
+ }
314
+
315
+ const dispatch = (handler: NodeHandler) => {
316
+ // better-call's getRequest resolves the path as baseUrl + url
317
+ const wrapped = Object.create(req, {
318
+ url: { value: dispatchPath + query, enumerable: true, configurable: true },
319
+ baseUrl: { value: '', enumerable: true, configurable: true },
320
+ originalUrl: { value: dispatchPath + query, enumerable: true, configurable: true },
321
+ });
322
+ handler(wrapped as any, res).catch(next);
323
+ };
324
+
325
+ const route = specificRouter.findRoute(r.method ?? 'GET', dispatchPath);
326
+ // rou3 normalizes trailing slashes in findRoute but processRequest
327
+ // exact-matches — only trust the hit when the slashes agree.
328
+ if (route && (!route.data?.path?.endsWith('/') || dispatchPath.endsWith('/'))) {
329
+ return dispatch(specificHandler);
330
+ }
331
+
332
+ // Asset request — only delegate when the file exists on disk, so the
333
+ // catch-all's SPA fallback can't mask sibling express routes.
334
+ if (r.method !== 'GET' || !staticRoot || !dispatchPath.startsWith(prefix)) { return next(); }
335
+ const rel = dispatchPath.slice(prefix.length).replace(/^\/+/, '');
336
+ if (!rel || rel.includes('..')) { return next(); }
337
+ const filePath = path.resolve(staticRoot, rel);
338
+ if (!filePath.startsWith(staticRoot + path.sep)) { return next(); }
339
+ fs.stat(filePath).then((stat) => stat.isFile() ? dispatch(fullHandler) : next()).catch(() => next());
340
+ };
341
+ }