@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
@@ -0,0 +1,311 @@
1
+ /**
2
+ * User → active sessions index: "which active rooms is user X in?"
3
+ *
4
+ * Without this, answering that question would require fanning out
5
+ * `getInspectorView()` across every running room — O(rooms) per lookup.
6
+ * Instead, each Room writes a small Presence hash entry on `_onJoin` and
7
+ * removes it on `_onAfterLeave` / dispose. The admin endpoint reads the
8
+ * hash with a single `hgetall`, then reconciles against the matchmaker's
9
+ * live room listing to drop stale entries left behind by hard crashes
10
+ * (no `onLeave` ran).
11
+ *
12
+ * Hash schema:
13
+ * key: colyseus:user-rooms:{userId}
14
+ * field: sessionId
15
+ * value: JSON `{ roomId, roomName, joinedAt }` (joinedAt is unix ms)
16
+ *
17
+ * Anonymous clients (no userId resolvable) are skipped — the index is
18
+ * for forensic / support workflows, not anonymous traffic. A Room can
19
+ * also opt out wholesale by setting `trackUserSessions = false` (e.g.
20
+ * a high-volume relay room that doesn't want to pay the Presence
21
+ * write per join).
22
+ *
23
+ * Per-Room state (which sessionIds have an entry, and under which userId)
24
+ * lives in a module-level WeakMap rather than as a field on Room itself.
25
+ * Two reasons:
26
+ *
27
+ * 1. Room.ts stays thin — it only knows about three call points
28
+ * (`trackRoomJoin`, `releaseRoomLeave`, `sweepRoomDispose`) and
29
+ * doesn't have to carry an extra Map field or two private helper
30
+ * methods solely for this concern.
31
+ * 2. The WeakMap is GC-tied to the Room — when a Room instance is
32
+ * collected, its entries vanish automatically. No explicit teardown
33
+ * hook needed beyond the dispose sweep that drains Presence.
34
+ *
35
+ * @internal
36
+ */
37
+ import type { Presence } from '../presence/Presence.ts';
38
+
39
+ export const USER_ROOMS_KEY_PREFIX = 'colyseus:user-rooms:';
40
+
41
+ export function userRoomsKey(userId: string): string {
42
+ return USER_ROOMS_KEY_PREFIX + userId;
43
+ }
44
+
45
+ /**
46
+ * What's serialized into the Presence hash value (sessionId is the
47
+ * hash field key, not part of the body). Internal write-side shape.
48
+ */
49
+ export interface UserRoomEntry {
50
+ roomId: string;
51
+ roomName: string;
52
+ joinedAt: number;
53
+ }
54
+
55
+ /**
56
+ * Public read-side shape returned by `listUserSessions`: a parsed
57
+ * `UserRoomEntry` plus its sessionId, optionally enriched with
58
+ * `processId` when reconcile against the matchmaker was on.
59
+ */
60
+ export interface UserSessionInfo extends UserRoomEntry {
61
+ sessionId: string;
62
+ /**
63
+ * Process hosting the room, per the matchmaker. Populated only
64
+ * when `listUserSessions` was called with `reconcile: true` AND
65
+ * the room is still in the matchmaker roster.
66
+ */
67
+ processId?: string;
68
+ }
69
+
70
+ /**
71
+ * Structural subset of `Room` needed by the index. Lets this module
72
+ * avoid importing `Room` (which would create a cycle) while still
73
+ * staying typed at the call site.
74
+ */
75
+ interface InspectorRoomShape {
76
+ roomId: string;
77
+ roomName: string;
78
+ presence: Presence;
79
+ }
80
+
81
+ /**
82
+ * Structural subset of a Client we read at join/leave. `userId` and
83
+ * `auth` are both optional — the index simply skips clients without
84
+ * either, which is the correct "anonymous traffic doesn't show up
85
+ * in support tooling" behavior.
86
+ */
87
+ interface InspectorClientShape {
88
+ sessionId: string;
89
+ userId?: string;
90
+ auth?: { id?: string } | null;
91
+ }
92
+
93
+ /**
94
+ * sessionId → userId for clients currently registered in the index,
95
+ * scoped per Room. WeakMap-keyed so a forgotten Room takes its tracking
96
+ * map with it.
97
+ */
98
+ const tracked = new WeakMap<InspectorRoomShape, Map<string, string>>();
99
+
100
+ function getTrackingMap(room: InspectorRoomShape): Map<string, string> {
101
+ let map = tracked.get(room);
102
+ if (!map) {
103
+ map = new Map();
104
+ tracked.set(room, map);
105
+ }
106
+ return map;
107
+ }
108
+
109
+ function resolveUserId(client: InspectorClientShape): string | undefined {
110
+ return client.userId ?? client.auth?.id;
111
+ }
112
+
113
+ /**
114
+ * Best-effort: write the join entry. Errors are swallowed because the
115
+ * index is observability metadata — a Presence outage shouldn't reject
116
+ * a player's join. Exposed as a pure helper for tests + the admin
117
+ * endpoint; `trackRoomJoin` is the Room-flavored entrypoint.
118
+ */
119
+ export async function trackUserSession(
120
+ presence: Presence,
121
+ userId: string,
122
+ sessionId: string,
123
+ entry: UserRoomEntry,
124
+ ): Promise<void> {
125
+ try {
126
+ await presence.hset(userRoomsKey(userId), sessionId, JSON.stringify(entry));
127
+ } catch {
128
+ // intentional: see fn-doc
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Best-effort: remove the join entry. Errors are swallowed so a Presence
134
+ * blip doesn't bubble into `_onAfterLeave` / `_dispose`.
135
+ */
136
+ export async function releaseUserSession(
137
+ presence: Presence,
138
+ userId: string,
139
+ sessionId: string,
140
+ ): Promise<void> {
141
+ try {
142
+ await presence.hdel(userRoomsKey(userId), sessionId);
143
+ } catch {
144
+ // intentional: see fn-doc
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Record `client`'s join under `room` in the reverse index. No-op for
150
+ * clients with no resolvable userId (anonymous), and when the room
151
+ * carries no presence (shouldn't happen — defensive for unit tests).
152
+ *
153
+ * Fire-and-forget against Presence; the in-memory tracking map updates
154
+ * synchronously so a follow-up `releaseRoomLeave` always finds the
155
+ * right userId even if the Presence write is still in flight.
156
+ */
157
+ export function trackRoomJoin(room: InspectorRoomShape, client: InspectorClientShape): void {
158
+ if (!room.presence) { return; }
159
+ const userId = resolveUserId(client);
160
+ if (!userId) { return; }
161
+ getTrackingMap(room).set(client.sessionId, userId);
162
+ const entry: UserRoomEntry = {
163
+ roomId: room.roomId,
164
+ roomName: room.roomName,
165
+ joinedAt: Date.now(),
166
+ };
167
+ void trackUserSession(room.presence, userId, client.sessionId, entry);
168
+ }
169
+
170
+ /**
171
+ * Drop `client`'s entry from the reverse index. Idempotent — no-op
172
+ * when the client wasn't tracked (anonymous, or tracking failed at
173
+ * join time).
174
+ */
175
+ export function releaseRoomLeave(room: InspectorRoomShape, client: InspectorClientShape): void {
176
+ if (!room.presence) { return; }
177
+ const map = tracked.get(room);
178
+ const userId = map?.get(client.sessionId);
179
+ if (!userId || !map) { return; }
180
+ map.delete(client.sessionId);
181
+ void releaseUserSession(room.presence, userId, client.sessionId);
182
+ }
183
+
184
+ /**
185
+ * Sweep any still-tracked sessions for `room`. Called during dispose to
186
+ * cover the case where `disconnect()` races the per-client `_onAfterLeave`
187
+ * path — the read-side reconcile handles cross-process crash recovery,
188
+ * but this is the cheap deterministic cleanup for a clean local dispose.
189
+ *
190
+ * Awaits the pending `hdel`s so the caller can sequence against "the
191
+ * index is now coherent" — the dispose path uses that ordering.
192
+ */
193
+ export async function sweepRoomDispose(room: InspectorRoomShape): Promise<void> {
194
+ if (!room.presence) { return; }
195
+ const map = tracked.get(room);
196
+ if (!map || map.size === 0) { return; }
197
+ const pending: Promise<void>[] = [];
198
+ for (const [sessionId, userId] of map) {
199
+ pending.push(releaseUserSession(room.presence, userId, sessionId));
200
+ }
201
+ map.clear();
202
+ await Promise.all(pending);
203
+ }
204
+
205
+ /**
206
+ * Minimal shape of a matchmaker room record needed for reconcile —
207
+ * keeps this module decoupled from the matchmaker / driver types.
208
+ * `matchMaker.query()`'s actual return (`IRoomCache[]`) is structurally
209
+ * a supertype of this, so callers can pass `matchMaker.query` directly.
210
+ */
211
+ interface MatchmakerRoomLike {
212
+ roomId: string;
213
+ processId?: string;
214
+ }
215
+
216
+ export interface ListUserSessionsOptions {
217
+ /**
218
+ * Drop entries whose `roomId` is no longer in the matchmaker roster
219
+ * (the index can lag a crashed process). When `true`, the returned
220
+ * entries also carry `processId` from the live room record.
221
+ *
222
+ * Off by default — most callers (kick everyone, count) don't need it
223
+ * and the extra `matchMaker.query` round-trip isn't free.
224
+ */
225
+ reconcile?: boolean;
226
+
227
+ /**
228
+ * Fire-and-forget `hdel` for stale entries — those dropped by
229
+ * reconcile (matchmaker doesn't know the room anymore) plus any
230
+ * with corrupt JSON. Lets read endpoints self-heal the index on
231
+ * each call. No-op when `reconcile` is `false`.
232
+ */
233
+ removeStale?: boolean;
234
+ }
235
+
236
+ /**
237
+ * Read the user → active sessions index. Pure helper — the
238
+ * `Presence` + matchmaker batch lookup are injected so this module
239
+ * stays free of matchmaker imports (and so unit tests can drive it
240
+ * with fake deps).
241
+ *
242
+ * Wire-op count per call:
243
+ * - 1 HGETALL on the user's hash (always).
244
+ * - 1 batch room lookup when `reconcile: true` AND there are
245
+ * entries to verify; skipped otherwise.
246
+ *
247
+ * Bounded at 2 wire ops regardless of the user's session count.
248
+ */
249
+ export async function listUserSessions(
250
+ presence: Presence,
251
+ findRooms: (roomIds: string[]) => Promise<Map<string, MatchmakerRoomLike>>,
252
+ userId: string,
253
+ options: ListUserSessionsOptions = {},
254
+ ): Promise<UserSessionInfo[]> {
255
+ const reconcile = options.reconcile === true;
256
+ const removeStale = reconcile && options.removeStale === true;
257
+
258
+ let raw: Record<string, string>;
259
+ try {
260
+ raw = await presence.hgetall(userRoomsKey(userId));
261
+ } catch {
262
+ // Presence outage — observability shouldn't bring down the caller.
263
+ return [];
264
+ }
265
+ const fields = Object.keys(raw);
266
+ if (fields.length === 0) { return []; }
267
+
268
+ const staleSessions: string[] = [];
269
+ const parsed: Array<{ sessionId: string; entry: UserRoomEntry }> = [];
270
+ for (const sessionId of fields) {
271
+ try {
272
+ parsed.push({ sessionId, entry: JSON.parse(raw[sessionId]) as UserRoomEntry });
273
+ } catch {
274
+ // Corrupt JSON — index drift. Removable even without reconcile.
275
+ staleSessions.push(sessionId);
276
+ }
277
+ }
278
+
279
+ if (!reconcile) {
280
+ return parsed.map(({ sessionId, entry }) => ({ sessionId, ...entry }));
281
+ }
282
+
283
+ // One batch lookup for the K roomIds we care about. K = entries
284
+ // surviving the JSON.parse stage, not cluster size.
285
+ const live = parsed.length > 0
286
+ ? await findRooms(parsed.map((p) => p.entry.roomId))
287
+ : new Map<string, MatchmakerRoomLike>();
288
+
289
+ const result: UserSessionInfo[] = [];
290
+ for (const { sessionId, entry } of parsed) {
291
+ const room = live.get(entry.roomId);
292
+ if (!room) {
293
+ // Matchmaker doesn't know this roomId anymore — stale entry
294
+ // from a crashed process. Drop it (and remove if requested).
295
+ staleSessions.push(sessionId);
296
+ continue;
297
+ }
298
+ const info: UserSessionInfo = { sessionId, ...entry };
299
+ if (room.processId !== undefined) { info.processId = room.processId; }
300
+ result.push(info);
301
+ }
302
+
303
+ if (removeStale && staleSessions.length > 0) {
304
+ const key = userRoomsKey(userId);
305
+ void Promise.all(
306
+ staleSessions.map((s) => presence.hdel(key, s)),
307
+ ).catch(() => { /* presence outage, swallow */ });
308
+ }
309
+
310
+ return result;
311
+ }