@colyseus/core 0.17.42 → 0.18.0

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 (90) hide show
  1. package/build/MatchMaker.cjs +19 -6
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.d.ts +10 -0
  4. package/build/MatchMaker.mjs +18 -6
  5. package/build/MatchMaker.mjs.map +2 -2
  6. package/build/Protocol.cjs +102 -37
  7. package/build/Protocol.cjs.map +2 -2
  8. package/build/Protocol.d.ts +33 -2
  9. package/build/Protocol.mjs +102 -37
  10. package/build/Protocol.mjs.map +2 -2
  11. package/build/Room.cjs +296 -19
  12. package/build/Room.cjs.map +3 -3
  13. package/build/Room.d.ts +186 -3
  14. package/build/Room.mjs +303 -21
  15. package/build/Room.mjs.map +3 -3
  16. package/build/RoomPlugin.cjs +252 -0
  17. package/build/RoomPlugin.cjs.map +7 -0
  18. package/build/RoomPlugin.d.ts +271 -0
  19. package/build/RoomPlugin.mjs +220 -0
  20. package/build/RoomPlugin.mjs.map +7 -0
  21. package/build/Server.cjs +40 -7
  22. package/build/Server.cjs.map +2 -2
  23. package/build/Server.d.ts +25 -0
  24. package/build/Server.mjs +41 -8
  25. package/build/Server.mjs.map +2 -2
  26. package/build/Transport.cjs +38 -2
  27. package/build/Transport.cjs.map +2 -2
  28. package/build/Transport.d.ts +40 -4
  29. package/build/Transport.mjs +38 -2
  30. package/build/Transport.mjs.map +2 -2
  31. package/build/index.cjs +11 -2
  32. package/build/index.cjs.map +2 -2
  33. package/build/index.d.ts +2 -1
  34. package/build/index.mjs +12 -2
  35. package/build/index.mjs.map +2 -2
  36. package/build/input/InputBuffer.cjs +113 -0
  37. package/build/input/InputBuffer.cjs.map +7 -0
  38. package/build/input/InputBuffer.d.ts +136 -0
  39. package/build/input/InputBuffer.mjs +86 -0
  40. package/build/input/InputBuffer.mjs.map +7 -0
  41. package/build/internal.cjs +61 -0
  42. package/build/internal.cjs.map +7 -0
  43. package/build/internal.d.ts +9 -0
  44. package/build/internal.mjs +29 -0
  45. package/build/internal.mjs.map +7 -0
  46. package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
  47. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  48. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
  49. package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
  50. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  51. package/build/matchmaker/driver.cjs.map +1 -1
  52. package/build/matchmaker/driver.d.ts +12 -0
  53. package/build/matchmaker/driver.mjs.map +1 -1
  54. package/build/presence/LocalPresence.d.ts +1 -1
  55. package/build/rooms/LobbyRoom.cjs +8 -10
  56. package/build/rooms/LobbyRoom.cjs.map +2 -2
  57. package/build/rooms/LobbyRoom.d.ts +4 -3
  58. package/build/rooms/LobbyRoom.mjs +8 -10
  59. package/build/rooms/LobbyRoom.mjs.map +2 -2
  60. package/build/rooms/RelayRoom.cjs +12 -16
  61. package/build/rooms/RelayRoom.cjs.map +2 -2
  62. package/build/rooms/RelayRoom.d.ts +32 -11
  63. package/build/rooms/RelayRoom.mjs +10 -16
  64. package/build/rooms/RelayRoom.mjs.map +2 -2
  65. package/build/router/index.cjs +65 -4
  66. package/build/router/index.cjs.map +2 -2
  67. package/build/router/index.d.ts +30 -6
  68. package/build/router/index.mjs +66 -6
  69. package/build/router/index.mjs.map +3 -3
  70. package/build/utils/UserSessionIndex.cjs +162 -0
  71. package/build/utils/UserSessionIndex.cjs.map +7 -0
  72. package/build/utils/UserSessionIndex.d.ts +166 -0
  73. package/build/utils/UserSessionIndex.mjs +130 -0
  74. package/build/utils/UserSessionIndex.mjs.map +7 -0
  75. package/package.json +19 -14
  76. package/src/MatchMaker.ts +40 -6
  77. package/src/Protocol.ts +130 -59
  78. package/src/Room.ts +475 -22
  79. package/src/RoomPlugin.ts +563 -0
  80. package/src/Server.ts +72 -11
  81. package/src/Transport.ts +76 -8
  82. package/src/index.ts +10 -1
  83. package/src/input/InputBuffer.ts +192 -0
  84. package/src/internal.ts +46 -0
  85. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  86. package/src/matchmaker/driver.ts +13 -0
  87. package/src/rooms/LobbyRoom.ts +12 -8
  88. package/src/rooms/RelayRoom.ts +9 -15
  89. package/src/router/index.ts +112 -11
  90. package/src/utils/UserSessionIndex.ts +311 -0
package/src/Transport.ts CHANGED
@@ -5,6 +5,7 @@ import type { Router } from '@colyseus/better-call';
5
5
 
6
6
  import { ErrorCode } from '@colyseus/shared-types';
7
7
  import { StateView } from '@colyseus/schema';
8
+ import type { InputDecoder } from '@colyseus/schema/input';
8
9
 
9
10
  import { EventEmitter } from 'events';
10
11
  import { spliceOne } from './utils/Utils.ts';
@@ -90,15 +91,10 @@ export interface Client<T extends { userData?: any, auth?: any, messages?: Recor
90
91
 
91
92
  ref: EventEmitter;
92
93
 
93
- /**
94
- * @deprecated use `sessionId` instead.
95
- */
96
- id: string;
97
-
98
94
  /**
99
95
  * Unique id per session.
100
96
  */
101
- sessionId: string; // TODO: remove sessionId on version 1.0.0
97
+ sessionId: string;
102
98
 
103
99
  /**
104
100
  * Connection state
@@ -198,15 +194,87 @@ export interface ClientPrivate {
198
194
  */
199
195
  _numMessagesLastSecond?: number;
200
196
  _lastMessageTime?: number;
197
+
198
+ /**
199
+ * Per-client input Schema instance, allocated on join when the Room
200
+ * declares `input`. Mutated in-place by {@link _inputDecoder} on each
201
+ * incoming ROOM_INPUT_* packet.
202
+ *
203
+ * Typed loosely (`any`) so duplicate `@colyseus/schema` installs don't
204
+ * trigger type-identity errors against user-defined input classes.
205
+ */
206
+ _input?: any;
207
+ _inputDecoder?: InputDecoder;
208
+
209
+ /**
210
+ * Per-client buffer of cloned input snapshots, allocated on join when
211
+ * `Room.inputOptions.bufferMaxSize > 0`. Populated on each decoded frame.
212
+ */
213
+ _inputBuffer?: import('./input/InputBuffer.ts').InputBufferImpl;
214
+
215
+ /**
216
+ * Cached per-client accessor returned by `room.input(sessionId)`. Built
217
+ * once at join (when the Room called `defineInput()`), so the public API
218
+ * call is a Map lookup + property read with no per-call allocation.
219
+ */
220
+ _inputAccessor?: import('./input/InputBuffer.ts').InputAccessor;
221
+
222
+ /**
223
+ * Used for rate limiting ROOM_INPUT_* packets via maxInputsPerSecond,
224
+ * independent of maxMessagesPerSecond.
225
+ */
226
+ _numInputsLastSecond?: number;
227
+ _lastInputTime?: number;
201
228
  }
202
229
 
203
230
  export class ClientArray<C extends Client = Client> extends Array<C> {
231
+ /**
232
+ * Secondary index for O(1) lookup by sessionId. Kept in sync by the
233
+ * mutating methods overridden below. Direct index assignment
234
+ * (`arr[i] = client`) and `arr.length = 0` bypass this index — use
235
+ * `push` / `splice` / `delete` / `pop` / `shift` / `unshift` instead.
236
+ */
237
+ private _byId: Map<string, C> = new Map();
238
+
204
239
  public getById(sessionId: string): C | undefined {
205
- return this.find((client) => client.sessionId === sessionId);
240
+ return this._byId.get(sessionId);
206
241
  }
207
242
 
208
243
  public delete(client: C): boolean {
209
- return spliceOne(this, this.indexOf(client));
244
+ const removed = spliceOne(this, this.indexOf(client));
245
+ if (removed) this._byId.delete(client.sessionId);
246
+ return removed;
247
+ }
248
+
249
+ public push(...items: C[]): number {
250
+ for (let i = 0; i < items.length; i++) this._byId.set(items[i].sessionId, items[i]);
251
+ return super.push(...items);
252
+ }
253
+
254
+ public pop(): C | undefined {
255
+ const removed = super.pop();
256
+ if (removed !== undefined) this._byId.delete(removed.sessionId);
257
+ return removed;
258
+ }
259
+
260
+ public shift(): C | undefined {
261
+ const removed = super.shift();
262
+ if (removed !== undefined) this._byId.delete(removed.sessionId);
263
+ return removed;
264
+ }
265
+
266
+ public unshift(...items: C[]): number {
267
+ for (let i = 0; i < items.length; i++) this._byId.set(items[i].sessionId, items[i]);
268
+ return super.unshift(...items);
269
+ }
270
+
271
+ public splice(start: number, deleteCount?: number, ...items: C[]): C[] {
272
+ const removed = (deleteCount === undefined)
273
+ ? super.splice(start)
274
+ : super.splice(start, deleteCount, ...items);
275
+ for (let i = 0; i < removed.length; i++) this._byId.delete(removed[i].sessionId);
276
+ for (let i = 0; i < items.length; i++) this._byId.set(items[i].sessionId, items[i]);
277
+ return removed;
210
278
  }
211
279
  }
212
280
 
package/src/index.ts CHANGED
@@ -13,6 +13,10 @@ export {
13
13
  // Core classes
14
14
  export { Server, defineRoom, defineServer, registerRoomDefinitions, unregisterRoomDefinitions, type RoomDefinitions, type ServerOptions, type SDKTypes } from './Server.ts';
15
15
  export { Room, room, RoomInternalState, validate, type RoomOptions, type MessageHandlerWithFormat, type Messages, type ExtractRoomState, type ExtractRoomMetadata, type ExtractRoomClient } from './Room.ts';
16
+ export {
17
+ RoomPlugin, definePlugins, attachToTestRoom,
18
+ type RoomPluginOrder, type PluginDependencies, type RoomPluginClass,
19
+ } from './RoomPlugin.ts';
16
20
  export { getMessageBytes } from './Protocol.ts';
17
21
  export { RegisteredHandler } from './matchmaker/RegisteredHandler.ts';
18
22
  export { ServerError } from './errors/ServerError.ts';
@@ -82,8 +86,10 @@ export {
82
86
  createInternalContext,
83
87
  createMiddleware,
84
88
  createRouter,
89
+ basicAuth,
90
+ type BasicAuthOptions,
85
91
  toNodeHandler,
86
- __globalEndpoints,
92
+ dualModeEndpoints,
87
93
  type Router,
88
94
  type RouterConfig,
89
95
  type Endpoint,
@@ -91,6 +97,9 @@ export {
91
97
  type EndpointOptions,
92
98
  type EndpointContext,
93
99
  type StrictEndpoint,
100
+ type ExpressMiddleware,
101
+ type NodeHandler,
102
+ type DualModeHelpers,
94
103
  } from './router/index.ts';
95
104
 
96
105
  // Abstract logging support
@@ -0,0 +1,192 @@
1
+ import type { ClientPrivate } from '../Transport.ts';
2
+
3
+ /**
4
+ * Names of fields on `I` whose values are `number` — used by
5
+ * `Room.defineInput()` to constrain `seqField` to actually-numeric fields
6
+ * on the input schema. Filters out booleans, strings, methods, etc.
7
+ */
8
+ export type NumericFieldsOf<I> = {
9
+ [K in keyof I]: I[K] extends number ? (K extends string ? K : never) : never;
10
+ }[keyof I];
11
+
12
+ /**
13
+ * Internal: input configuration captured by `Room.defineInput()`. The schema
14
+ * constructor is stored here so the runtime doesn't need to know it through
15
+ * the public `room.input` (which is now a callable accessor).
16
+ *
17
+ * @internal
18
+ */
19
+ export interface InputOptions {
20
+ /**
21
+ * Schema constructor used to allocate per-client input instances on join.
22
+ * Captured by `defineInput()` from its `type` argument.
23
+ *
24
+ * Typed loosely (`new () => any`) to sidestep type-identity issues across
25
+ * duplicate `@colyseus/schema` installs; the runtime calls
26
+ * `instance.clone()` and friends, which match by shape.
27
+ */
28
+ ctor: new () => any;
29
+
30
+ /**
31
+ * Name of a monotonically-increasing numeric field on the input schema used
32
+ * to order and dedupe incoming frames. When set, the framework:
33
+ * - Drops redundant frames (`input[seqField]` ≤ the last-seen value are
34
+ * discarded before they enter the buffer). Matches the unreliable-mode
35
+ * ring-redundancy pattern out of the box.
36
+ * - Powers `room.input(sessionId).at(value)` lookups.
37
+ *
38
+ * Despite the name, "seq" here is broader than an integer counter — any
39
+ * monotonic numeric field works:
40
+ * - **Sequence counter** (`"seq"`, `"tick"`, `"frame"`) — typical for
41
+ * lockstep / rollback netcode (Photon Quantum, GGPO).
42
+ * - **Timestamp** (`"timestamp"`, milliseconds or seconds) — useful for
43
+ * variable-rate clients, lag compensation, hit registration (Unreal CMC
44
+ * uses float-seconds timestamps via `FSavedMove_Character.TimeStamp`).
45
+ *
46
+ * Whichever you use, the field must increase monotonically across frames
47
+ * for dedupe to work.
48
+ */
49
+ seqField?: string;
50
+
51
+ /**
52
+ * > 0 enables per-client buffering of cloned snapshots — required for
53
+ * `room.input(sessionId).drain() / .peek() / .at()` to return populated
54
+ * data. Oldest drops on overflow. Set to `0` to disable (`.latest` still
55
+ * works).
56
+ */
57
+ bufferMaxSize: number;
58
+ }
59
+
60
+ /**
61
+ * Per-client input accessor returned by `room.input(sessionId)`. Combines the
62
+ * latest decoded instance with the (optional) snapshot ring buffer.
63
+ *
64
+ * - {@link latest} — the bound Schema instance, mutated in place by the
65
+ * decoder. Cheapest read; use when only the most recent state matters.
66
+ * - {@link drain} / {@link peek} / {@link at} — populated when
67
+ * `defineInput()` was called with `bufferMaxSize > 0` (default 32).
68
+ * Use for rollback netcode / lockstep where every frame matters.
69
+ *
70
+ * Returned for unknown sessionIds and rooms without `defineInput()` is a
71
+ * frozen no-op accessor (latest=undefined, drain/peek=[], at=undefined,
72
+ * size=0, clear=no-op).
73
+ */
74
+ export interface InputAccessor<I = any> {
75
+ /** Latest decoded input. `undefined` when unknown sessionId or no input declared. */
76
+ readonly latest: I | undefined;
77
+
78
+ /**
79
+ * Find the buffered snapshot whose `[seqField]` equals `value`. The field
80
+ * name is the Room's `defineInput()` `seqField`. Linear scan — cheap for
81
+ * typical buffer sizes; not intended for very large rings. Returns
82
+ * `undefined` when no match is buffered (or `seqField` isn't configured).
83
+ *
84
+ * Useful for tick-aligned retrieval (lockstep, rollback).
85
+ */
86
+ at(value: number): I | undefined;
87
+
88
+ /** Take everything buffered (oldest → newest) and clear. Snapshots are safe to retain. */
89
+ drain(): I[];
90
+
91
+ /** Read everything buffered without consuming. */
92
+ peek(): I[];
93
+
94
+ /** Number of snapshots currently buffered. */
95
+ readonly size: number;
96
+
97
+ /** Drop all buffered snapshots (also resets the dedupe tracker). */
98
+ clear(): void;
99
+ }
100
+
101
+ /**
102
+ * Callable returned by `Room.defineInput()`. Assign it to `this.input` and
103
+ * call `room.input(sessionId)` per tick to read each client's latest input
104
+ * and/or buffered snapshots.
105
+ */
106
+ export type InputAPI<I = any> = (sessionId: string) => InputAccessor<I>;
107
+
108
+ /** @internal */
109
+ export class InputBufferImpl<I = any> {
110
+ private _items: I[] = [];
111
+ private _lastSeq: number = -Infinity;
112
+ private readonly _maxSize: number;
113
+ private readonly _seqField: string | undefined;
114
+
115
+ constructor(maxSize: number, seqField: string | undefined) {
116
+ this._maxSize = maxSize;
117
+ this._seqField = seqField;
118
+ }
119
+
120
+ push(snapshot: I): void {
121
+ this._items.push(snapshot);
122
+ if (this._items.length > this._maxSize) { this._items.shift(); }
123
+ }
124
+
125
+ /** Returns true if `value` hasn't been seen, and updates the last-seen marker. */
126
+ accept(value: number): boolean {
127
+ if (value <= this._lastSeq) { return false; }
128
+ this._lastSeq = value;
129
+ return true;
130
+ }
131
+
132
+ drain(): I[] {
133
+ const out = this._items;
134
+ this._items = [];
135
+ return out;
136
+ }
137
+
138
+ peek(): I[] {
139
+ return this._items.slice();
140
+ }
141
+
142
+ at(value: number): I | undefined {
143
+ if (this._seqField === undefined) { return undefined; }
144
+ for (let i = 0; i < this._items.length; i++) {
145
+ if ((this._items[i] as any)[this._seqField] === value) { return this._items[i]; }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ get size(): number {
151
+ return this._items.length;
152
+ }
153
+
154
+ clear(): void {
155
+ this._items.length = 0;
156
+ this._lastSeq = -Infinity;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Default per-client accessor. Reads `_input` and `_inputBuffer` off the
162
+ * client at access time — both are nullable until the room declares input
163
+ * via `defineInput()`. Cached as `client._inputAccessor` at join, so
164
+ * `room.input(sessionId)` is a Map lookup + property read.
165
+ *
166
+ * @internal
167
+ */
168
+ export class InputAccessorImpl<I = any> implements InputAccessor<I> {
169
+ private _client: ClientPrivate;
170
+ constructor(client: ClientPrivate) { this._client = client; }
171
+ get latest(): I | undefined { return this._client._input as I | undefined; }
172
+ at(value: number): I | undefined { return this._client._inputBuffer?.at(value) as I | undefined; }
173
+ drain(): I[] { return (this._client._inputBuffer?.drain() ?? []) as I[]; }
174
+ peek(): I[] { return (this._client._inputBuffer?.peek() ?? []) as I[]; }
175
+ get size(): number { return this._client._inputBuffer?.size ?? 0; }
176
+ clear(): void { this._client._inputBuffer?.clear(); }
177
+ }
178
+
179
+ /**
180
+ * Returned by `room.input(sessionId)` for unknown sessions and for rooms
181
+ * that didn't call `defineInput()`.
182
+ *
183
+ * @internal
184
+ */
185
+ export const NO_OP_INPUT_ACCESSOR: InputAccessor<any> = Object.freeze({
186
+ latest: undefined,
187
+ at: () => undefined,
188
+ drain: () => [],
189
+ peek: () => [],
190
+ size: 0,
191
+ clear: () => {},
192
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Framework-internal entry — symbols used by the bundled `colyseus`
3
+ * plugins (track-user-sessions, unique-session) and the admin
4
+ * backend, but NOT part of `@colyseus/core`'s public surface.
5
+ *
6
+ * Third parties should reach for `TrackUserSessionsPlugin` and its
7
+ * `listUserSessions` static instead — the raw helpers below are
8
+ * implementation details and can change between minor versions.
9
+ *
10
+ * Resolved via the `./*` wildcard in `packages/core/package.json`.
11
+ *
12
+ * @internal
13
+ */
14
+ import * as matchMaker from './MatchMaker.ts';
15
+ import {
16
+ listUserSessions,
17
+ type ListUserSessionsOptions,
18
+ type UserSessionInfo,
19
+ } from './utils/UserSessionIndex.ts';
20
+
21
+ export {
22
+ userRoomsKey,
23
+ USER_ROOMS_KEY_PREFIX,
24
+ trackUserSession,
25
+ releaseUserSession,
26
+ trackRoomJoin,
27
+ releaseRoomLeave,
28
+ sweepRoomDispose,
29
+ listUserSessions,
30
+ type UserRoomEntry,
31
+ type UserSessionInfo,
32
+ type ListUserSessionsOptions,
33
+ } from './utils/UserSessionIndex.ts';
34
+
35
+ /**
36
+ * `listUserSessions` wired to the live `matchMaker` — what
37
+ * `TrackUserSessionsPlugin.listUserSessions` and the admin backend
38
+ * actually call. The pure `listUserSessions` is kept exported above
39
+ * so unit tests can drive it with fake presence + findRooms.
40
+ */
41
+ export function listUserSessionsLive(
42
+ userId: string,
43
+ options?: ListUserSessionsOptions,
44
+ ): Promise<UserSessionInfo[]> {
45
+ return listUserSessions(matchMaker.presence, matchMaker.findRoomsByIds, userId, options);
46
+ }
@@ -40,6 +40,16 @@ export class LocalDriver implements MatchMakerDriver {
40
40
  return query as unknown as Promise<IRoomCache>;
41
41
  }
42
42
 
43
+ public async findByIds(roomIds: string[]): Promise<Map<string, IRoomCache>> {
44
+ const result = new Map<string, IRoomCache>();
45
+ if (roomIds.length === 0) { return result; }
46
+ const wanted = new Set(roomIds);
47
+ for (const room of this.rooms) {
48
+ if (wanted.has(room.roomId)) { result.set(room.roomId, room); }
49
+ }
50
+ return result;
51
+ }
52
+
43
53
  public update(room: IRoomCache, operations: Partial<{ $set: Partial<IRoomCache>, $inc: Partial<IRoomCache> }>) {
44
54
  if (operations.$set) {
45
55
  for (const field in operations.$set) {
@@ -147,6 +147,19 @@ export interface MatchMakerDriver {
147
147
  sortOptions?: SortOptions
148
148
  ): Promise<IRoomCache<ExtractRoomCacheMetadata<T>>>;
149
149
 
150
+ /**
151
+ * Batch-resolve room caches by roomId in a single backend round
152
+ * trip. Missing roomIds are absent from the returned map. Used by
153
+ * hot paths (per-join uniqueness check, by-user reverse-index
154
+ * lookups) where K serial `findOne` calls would multiply round
155
+ * trips needlessly.
156
+ *
157
+ * @param roomIds - Room ids to look up.
158
+ *
159
+ * @returns Map keyed by roomId of the rooms that were found.
160
+ */
161
+ findByIds(roomIds: string[]): Promise<Map<string, IRoomCache>>;
162
+
150
163
  /**
151
164
  * Remove a room from room cache.
152
165
  *
@@ -31,25 +31,29 @@ export interface LobbyOptions {
31
31
  filter?: FilterInput;
32
32
  }
33
33
 
34
+ export type LobbyMessages = {
35
+ filter: (client: LobbyClient, filter: FilterInput) => void;
36
+ };
37
+
34
38
  export class LobbyRoom<Metadata = any> extends Room {
35
39
  public rooms: IRoomCache<Metadata>[] = [];
36
40
  public unsubscribeLobby: () => void;
37
41
 
38
42
  public clientOptions: { [sessionId: string]: LobbyOptions } = {};
39
43
 
40
- messages = {
41
- filter: (client: LobbyClient, filter: FilterInput) => {
44
+ declare messages: LobbyMessages;
45
+
46
+ public async onCreate(options: any) {
47
+ // prevent LobbyRoom to notify itself
48
+ this['_listing'].unlisted = true;
49
+
50
+ this.onMessage('filter', (client, filter) => {
42
51
  const clientOptions = this.clientOptions[client.sessionId];
43
52
  if (!clientOptions) { return; }
44
53
 
45
54
  clientOptions.filter = filter;
46
55
  client.send('rooms', this.filterItemsForClient(clientOptions));
47
- }
48
- }
49
-
50
- public async onCreate(options: any) {
51
- // prevent LobbyRoom to notify itself
52
- this['_listing'].unlisted = true;
56
+ });
53
57
 
54
58
  this.unsubscribeLobby = await subscribeLobby((roomId, data) => {
55
59
  const roomIndex = this.rooms.findIndex((room) => room.roomId === roomId);
@@ -1,26 +1,20 @@
1
1
  import { CloseCode } from '@colyseus/shared-types';
2
- import { defineTypes, MapSchema, Schema } from '@colyseus/schema';
2
+ import { schema, t, type SchemaType } from '@colyseus/schema';
3
3
 
4
4
  import { Room } from '../Room.ts';
5
5
  import type { Client } from '../Transport.ts';
6
6
 
7
- class Player extends Schema {
8
- public connected: boolean;
9
- public name: string;
10
- public sessionId: string;
11
- }
12
- defineTypes(Player, {
13
- connected: 'boolean',
14
- name: 'string',
15
- sessionId: 'string',
7
+ export const Player = schema({
8
+ connected: t.boolean(),
9
+ name: t.string(),
10
+ sessionId: t.string(),
16
11
  });
12
+ export type Player = SchemaType<typeof Player>;
17
13
 
18
- class State extends Schema {
19
- public players = new MapSchema<Player>();
20
- }
21
- defineTypes(State, {
22
- players: { map: Player },
14
+ export const State = schema({
15
+ players: t.map(Player),
23
16
  });
17
+ export type State = SchemaType<typeof State>;
24
18
 
25
19
  /**
26
20
  * client.joinOrCreate("relayroom", {
@@ -1,6 +1,7 @@
1
1
  import type express from "express";
2
2
  import type { IncomingMessage, ServerResponse } from "http";
3
- import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint } from "@colyseus/better-call";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
+ import { type Endpoint, type Router, type RouterConfig, createRouter as createBetterCallRouter, createEndpoint, createMiddleware, APIError } from "@colyseus/better-call";
4
5
  import { toNodeHandler, getRequest, setResponse } from "@colyseus/better-call/node";
5
6
  import { Transport } from "../Transport.ts";
6
7
  import { controller } from "../matchmaker/controller.ts";
@@ -120,19 +121,119 @@ function expressRootRoute(expressApp: express.Application) {
120
121
  return stack.find((layer: any) => layer.match('/') && !['query', 'expressInit'].includes(layer.name));
121
122
  }
122
123
 
123
- /**
124
- * Do not use this directly. This is used internally by `@colyseus/playground`.
125
- * TODO: refactor. Avoid using globals.
126
- * @internal
127
- */
128
- export let __globalEndpoints: Record<string, Endpoint> = {};
129
-
130
124
  export function createRouter<
131
125
  E extends Record<string, Endpoint>,
132
126
  Config extends RouterConfig
133
127
  >(endpoints: E, config: Config = {} as Config) {
134
- // TODO: refactor. Avoid using globals.
135
- __globalEndpoints = endpoints;
136
-
137
128
  return createBetterCallRouter({ ...endpoints }, config);
138
129
  }
130
+
131
+ export interface BasicAuthOptions {
132
+ /** username → password. The common static case. */
133
+ users?: Record<string, string>;
134
+ /** Custom validator (e.g. DB-backed). Takes precedence over `users`. */
135
+ validate?: (username: string, password: string) => boolean | Promise<boolean>;
136
+ /** Realm shown in the browser prompt. Default 'Restricted'. */
137
+ realm?: string;
138
+ }
139
+
140
+ /**
141
+ * HTTP Basic Auth middleware. Drop into any endpoint's `use:` slot to gate
142
+ * it behind a browser credentials prompt:
143
+ *
144
+ * playground({ use: [basicAuth({ users: { admin: 's3cret' } })] })
145
+ */
146
+ export function basicAuth(opts: BasicAuthOptions) {
147
+ const { users, validate } = opts;
148
+ if (!users && !validate) {
149
+ throw new Error('[basicAuth] provide `users` or `validate`');
150
+ }
151
+ // Realm is interpolated into a header — strip `"` so it can't break out.
152
+ const challenge = `Basic realm="${(opts.realm ?? 'Restricted').replace(/"/g, '')}", charset="UTF-8"`;
153
+
154
+ return createMiddleware(async (ctx) => {
155
+ const creds = parseBasicHeader(ctx.getHeader('authorization'));
156
+ const ok = !!creds && (validate
157
+ ? await validate(creds.username, creds.password)
158
+ : staticCheck(users!, creds.username, creds.password));
159
+ if (!ok) {
160
+ throw new APIError(401, { message: 'authentication required' }, { 'WWW-Authenticate': challenge });
161
+ }
162
+ });
163
+ }
164
+
165
+ function parseBasicHeader(header: string | null | undefined) {
166
+ if (!header) { return null; }
167
+ const sep = header.indexOf(' ');
168
+ if (sep < 0 || header.slice(0, sep).toLowerCase() !== 'basic') { return null; }
169
+ let decoded: string;
170
+ try { decoded = Buffer.from(header.slice(sep + 1), 'base64').toString('utf8'); } catch { return null; }
171
+ const colon = decoded.indexOf(':');
172
+ if (colon < 0) { return null; }
173
+ return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
174
+ }
175
+
176
+ function staticCheck(users: Record<string, string>, username: string, password: string): boolean {
177
+ const expected = Object.prototype.hasOwnProperty.call(users, username) ? users[username] : undefined;
178
+ // Compare even for an unknown user so reject timing doesn't reveal which
179
+ // usernames exist.
180
+ return safeEqual(password, expected ?? '\0') && expected !== undefined;
181
+ }
182
+
183
+ // Hash both sides first: equalizes length (timingSafeEqual throws on a
184
+ // length mismatch, which would itself leak the secret's length).
185
+ function safeEqual(a: string, b: string): boolean {
186
+ return timingSafeEqual(
187
+ createHash('sha256').update(a).digest(),
188
+ createHash('sha256').update(b).digest(),
189
+ );
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // dualModeEndpoints — shared express-compat layer for @colyseus/admin,
194
+ // @colyseus/monitor, @colyseus/playground. Builds the two local routers
195
+ // (specific = no catch-all, full = everything) and the matching node
196
+ // handlers, then packages the express middleware so the return value works
197
+ // both as `{...spread}` into createRouter AND as `app.use("/", x)` middleware.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export type ExpressMiddleware = (
201
+ req: IncomingMessage,
202
+ res: ServerResponse,
203
+ next: (err?: any) => void,
204
+ ) => void;
205
+
206
+ export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
207
+
208
+ export interface DualModeHelpers {
209
+ specificRouter: Router;
210
+ specificHandler: NodeHandler;
211
+ fullRouter: Router;
212
+ fullHandler: NodeHandler;
213
+ }
214
+
215
+ export function dualModeEndpoints<E extends Record<string, Endpoint>>(
216
+ endpoints: E,
217
+ opts: {
218
+ /** Key in `endpoints` whose path is a catch-all. Excluded from `specificRouter` so it doesn't eat fall-through decisions. */
219
+ catchAllKey?: keyof E;
220
+ /** Build the express middleware given the pre-built routers + node handlers. */
221
+ buildMiddleware: (helpers: DualModeHelpers) => ExpressMiddleware;
222
+ },
223
+ ): ExpressMiddleware & E {
224
+ const fullRouter = createRouter(endpoints);
225
+ const fullHandler = toNodeHandler(fullRouter.handler) as NodeHandler;
226
+
227
+ const specificEndpoints = opts.catchAllKey
228
+ ? Object.fromEntries(
229
+ Object.entries(endpoints).filter(([k]) => k !== opts.catchAllKey),
230
+ ) as Partial<E>
231
+ : endpoints;
232
+ const specificRouter = createRouter(specificEndpoints as E);
233
+ const specificHandler = toNodeHandler(specificRouter.handler) as NodeHandler;
234
+
235
+ const middleware = opts.buildMiddleware({
236
+ specificRouter, specificHandler, fullRouter, fullHandler,
237
+ });
238
+ return Object.assign(middleware, endpoints) as ExpressMiddleware & E;
239
+ }