@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.
- package/build/MatchMaker.cjs +19 -6
- package/build/MatchMaker.cjs.map +2 -2
- package/build/MatchMaker.d.ts +10 -0
- package/build/MatchMaker.mjs +18 -6
- package/build/MatchMaker.mjs.map +2 -2
- package/build/Protocol.cjs +102 -37
- package/build/Protocol.cjs.map +2 -2
- package/build/Protocol.d.ts +33 -2
- package/build/Protocol.mjs +102 -37
- package/build/Protocol.mjs.map +2 -2
- package/build/Room.cjs +296 -19
- package/build/Room.cjs.map +3 -3
- package/build/Room.d.ts +186 -3
- package/build/Room.mjs +303 -21
- package/build/Room.mjs.map +3 -3
- package/build/RoomPlugin.cjs +252 -0
- package/build/RoomPlugin.cjs.map +7 -0
- package/build/RoomPlugin.d.ts +271 -0
- package/build/RoomPlugin.mjs +220 -0
- package/build/RoomPlugin.mjs.map +7 -0
- package/build/Server.cjs +40 -7
- package/build/Server.cjs.map +2 -2
- package/build/Server.d.ts +25 -0
- package/build/Server.mjs +41 -8
- package/build/Server.mjs.map +2 -2
- package/build/Transport.cjs +38 -2
- package/build/Transport.cjs.map +2 -2
- package/build/Transport.d.ts +40 -4
- package/build/Transport.mjs +38 -2
- package/build/Transport.mjs.map +2 -2
- package/build/index.cjs +11 -2
- package/build/index.cjs.map +2 -2
- package/build/index.d.ts +2 -1
- package/build/index.mjs +12 -2
- package/build/index.mjs.map +2 -2
- package/build/input/InputBuffer.cjs +113 -0
- package/build/input/InputBuffer.cjs.map +7 -0
- package/build/input/InputBuffer.d.ts +136 -0
- package/build/input/InputBuffer.mjs +86 -0
- package/build/input/InputBuffer.mjs.map +7 -0
- package/build/internal.cjs +61 -0
- package/build/internal.cjs.map +7 -0
- package/build/internal.d.ts +9 -0
- package/build/internal.mjs +29 -0
- package/build/internal.mjs.map +7 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
- package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
- package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
- package/build/matchmaker/driver.cjs.map +1 -1
- package/build/matchmaker/driver.d.ts +12 -0
- package/build/matchmaker/driver.mjs.map +1 -1
- package/build/presence/LocalPresence.d.ts +1 -1
- package/build/rooms/LobbyRoom.cjs +8 -10
- package/build/rooms/LobbyRoom.cjs.map +2 -2
- package/build/rooms/LobbyRoom.d.ts +4 -3
- package/build/rooms/LobbyRoom.mjs +8 -10
- package/build/rooms/LobbyRoom.mjs.map +2 -2
- package/build/rooms/RelayRoom.cjs +12 -16
- package/build/rooms/RelayRoom.cjs.map +2 -2
- package/build/rooms/RelayRoom.d.ts +32 -11
- package/build/rooms/RelayRoom.mjs +10 -16
- package/build/rooms/RelayRoom.mjs.map +2 -2
- package/build/router/index.cjs +65 -4
- package/build/router/index.cjs.map +2 -2
- package/build/router/index.d.ts +30 -6
- package/build/router/index.mjs +66 -6
- package/build/router/index.mjs.map +3 -3
- package/build/utils/UserSessionIndex.cjs +162 -0
- package/build/utils/UserSessionIndex.cjs.map +7 -0
- package/build/utils/UserSessionIndex.d.ts +166 -0
- package/build/utils/UserSessionIndex.mjs +130 -0
- package/build/utils/UserSessionIndex.mjs.map +7 -0
- package/package.json +19 -14
- package/src/MatchMaker.ts +40 -6
- package/src/Protocol.ts +130 -59
- package/src/Room.ts +475 -22
- package/src/RoomPlugin.ts +563 -0
- package/src/Server.ts +72 -11
- package/src/Transport.ts +76 -8
- package/src/index.ts +10 -1
- package/src/input/InputBuffer.ts +192 -0
- package/src/internal.ts +46 -0
- package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
- package/src/matchmaker/driver.ts +13 -0
- package/src/rooms/LobbyRoom.ts +12 -8
- package/src/rooms/RelayRoom.ts +9 -15
- package/src/router/index.ts +112 -11
- 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;
|
|
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.
|
|
240
|
+
return this._byId.get(sessionId);
|
|
206
241
|
}
|
|
207
242
|
|
|
208
243
|
public delete(client: C): boolean {
|
|
209
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/internal.ts
ADDED
|
@@ -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) {
|
package/src/matchmaker/driver.ts
CHANGED
|
@@ -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
|
*
|
package/src/rooms/LobbyRoom.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/rooms/RelayRoom.ts
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import { CloseCode } from '@colyseus/shared-types';
|
|
2
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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", {
|
package/src/router/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type express from "express";
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
3
|
-
import {
|
|
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
|
+
}
|