@colyseus/core 0.17.43 → 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 (95) 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 +49 -15
  22. package/build/Server.cjs.map +2 -2
  23. package/build/Server.d.ts +25 -0
  24. package/build/Server.mjs +50 -16
  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/Env.cjs +4 -8
  71. package/build/utils/Env.cjs.map +3 -3
  72. package/build/utils/Env.mjs +4 -8
  73. package/build/utils/Env.mjs.map +2 -2
  74. package/build/utils/UserSessionIndex.cjs +162 -0
  75. package/build/utils/UserSessionIndex.cjs.map +7 -0
  76. package/build/utils/UserSessionIndex.d.ts +166 -0
  77. package/build/utils/UserSessionIndex.mjs +130 -0
  78. package/build/utils/UserSessionIndex.mjs.map +7 -0
  79. package/package.json +20 -15
  80. package/src/MatchMaker.ts +40 -6
  81. package/src/Protocol.ts +130 -59
  82. package/src/Room.ts +475 -22
  83. package/src/RoomPlugin.ts +563 -0
  84. package/src/Server.ts +81 -22
  85. package/src/Transport.ts +76 -8
  86. package/src/index.ts +10 -1
  87. package/src/input/InputBuffer.ts +192 -0
  88. package/src/internal.ts +46 -0
  89. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  90. package/src/matchmaker/driver.ts +13 -0
  91. package/src/rooms/LobbyRoom.ts +12 -8
  92. package/src/rooms/RelayRoom.ts +9 -15
  93. package/src/router/index.ts +112 -11
  94. package/src/utils/Env.ts +4 -12
  95. package/src/utils/UserSessionIndex.ts +311 -0
@@ -0,0 +1,166 @@
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
+ export declare const USER_ROOMS_KEY_PREFIX = "colyseus:user-rooms:";
39
+ export declare function userRoomsKey(userId: string): string;
40
+ /**
41
+ * What's serialized into the Presence hash value (sessionId is the
42
+ * hash field key, not part of the body). Internal write-side shape.
43
+ */
44
+ export interface UserRoomEntry {
45
+ roomId: string;
46
+ roomName: string;
47
+ joinedAt: number;
48
+ }
49
+ /**
50
+ * Public read-side shape returned by `listUserSessions`: a parsed
51
+ * `UserRoomEntry` plus its sessionId, optionally enriched with
52
+ * `processId` when reconcile against the matchmaker was on.
53
+ */
54
+ export interface UserSessionInfo extends UserRoomEntry {
55
+ sessionId: string;
56
+ /**
57
+ * Process hosting the room, per the matchmaker. Populated only
58
+ * when `listUserSessions` was called with `reconcile: true` AND
59
+ * the room is still in the matchmaker roster.
60
+ */
61
+ processId?: string;
62
+ }
63
+ /**
64
+ * Structural subset of `Room` needed by the index. Lets this module
65
+ * avoid importing `Room` (which would create a cycle) while still
66
+ * staying typed at the call site.
67
+ */
68
+ interface InspectorRoomShape {
69
+ roomId: string;
70
+ roomName: string;
71
+ presence: Presence;
72
+ }
73
+ /**
74
+ * Structural subset of a Client we read at join/leave. `userId` and
75
+ * `auth` are both optional — the index simply skips clients without
76
+ * either, which is the correct "anonymous traffic doesn't show up
77
+ * in support tooling" behavior.
78
+ */
79
+ interface InspectorClientShape {
80
+ sessionId: string;
81
+ userId?: string;
82
+ auth?: {
83
+ id?: string;
84
+ } | null;
85
+ }
86
+ /**
87
+ * Best-effort: write the join entry. Errors are swallowed because the
88
+ * index is observability metadata — a Presence outage shouldn't reject
89
+ * a player's join. Exposed as a pure helper for tests + the admin
90
+ * endpoint; `trackRoomJoin` is the Room-flavored entrypoint.
91
+ */
92
+ export declare function trackUserSession(presence: Presence, userId: string, sessionId: string, entry: UserRoomEntry): Promise<void>;
93
+ /**
94
+ * Best-effort: remove the join entry. Errors are swallowed so a Presence
95
+ * blip doesn't bubble into `_onAfterLeave` / `_dispose`.
96
+ */
97
+ export declare function releaseUserSession(presence: Presence, userId: string, sessionId: string): Promise<void>;
98
+ /**
99
+ * Record `client`'s join under `room` in the reverse index. No-op for
100
+ * clients with no resolvable userId (anonymous), and when the room
101
+ * carries no presence (shouldn't happen — defensive for unit tests).
102
+ *
103
+ * Fire-and-forget against Presence; the in-memory tracking map updates
104
+ * synchronously so a follow-up `releaseRoomLeave` always finds the
105
+ * right userId even if the Presence write is still in flight.
106
+ */
107
+ export declare function trackRoomJoin(room: InspectorRoomShape, client: InspectorClientShape): void;
108
+ /**
109
+ * Drop `client`'s entry from the reverse index. Idempotent — no-op
110
+ * when the client wasn't tracked (anonymous, or tracking failed at
111
+ * join time).
112
+ */
113
+ export declare function releaseRoomLeave(room: InspectorRoomShape, client: InspectorClientShape): void;
114
+ /**
115
+ * Sweep any still-tracked sessions for `room`. Called during dispose to
116
+ * cover the case where `disconnect()` races the per-client `_onAfterLeave`
117
+ * path — the read-side reconcile handles cross-process crash recovery,
118
+ * but this is the cheap deterministic cleanup for a clean local dispose.
119
+ *
120
+ * Awaits the pending `hdel`s so the caller can sequence against "the
121
+ * index is now coherent" — the dispose path uses that ordering.
122
+ */
123
+ export declare function sweepRoomDispose(room: InspectorRoomShape): Promise<void>;
124
+ /**
125
+ * Minimal shape of a matchmaker room record needed for reconcile —
126
+ * keeps this module decoupled from the matchmaker / driver types.
127
+ * `matchMaker.query()`'s actual return (`IRoomCache[]`) is structurally
128
+ * a supertype of this, so callers can pass `matchMaker.query` directly.
129
+ */
130
+ interface MatchmakerRoomLike {
131
+ roomId: string;
132
+ processId?: string;
133
+ }
134
+ export interface ListUserSessionsOptions {
135
+ /**
136
+ * Drop entries whose `roomId` is no longer in the matchmaker roster
137
+ * (the index can lag a crashed process). When `true`, the returned
138
+ * entries also carry `processId` from the live room record.
139
+ *
140
+ * Off by default — most callers (kick everyone, count) don't need it
141
+ * and the extra `matchMaker.query` round-trip isn't free.
142
+ */
143
+ reconcile?: boolean;
144
+ /**
145
+ * Fire-and-forget `hdel` for stale entries — those dropped by
146
+ * reconcile (matchmaker doesn't know the room anymore) plus any
147
+ * with corrupt JSON. Lets read endpoints self-heal the index on
148
+ * each call. No-op when `reconcile` is `false`.
149
+ */
150
+ removeStale?: boolean;
151
+ }
152
+ /**
153
+ * Read the user → active sessions index. Pure helper — the
154
+ * `Presence` + matchmaker batch lookup are injected so this module
155
+ * stays free of matchmaker imports (and so unit tests can drive it
156
+ * with fake deps).
157
+ *
158
+ * Wire-op count per call:
159
+ * - 1 HGETALL on the user's hash (always).
160
+ * - 1 batch room lookup when `reconcile: true` AND there are
161
+ * entries to verify; skipped otherwise.
162
+ *
163
+ * Bounded at 2 wire ops regardless of the user's session count.
164
+ */
165
+ export declare function listUserSessions(presence: Presence, findRooms: (roomIds: string[]) => Promise<Map<string, MatchmakerRoomLike>>, userId: string, options?: ListUserSessionsOptions): Promise<UserSessionInfo[]>;
166
+ export {};
@@ -0,0 +1,130 @@
1
+ // packages/core/src/utils/UserSessionIndex.ts
2
+ var USER_ROOMS_KEY_PREFIX = "colyseus:user-rooms:";
3
+ function userRoomsKey(userId) {
4
+ return USER_ROOMS_KEY_PREFIX + userId;
5
+ }
6
+ var tracked = /* @__PURE__ */ new WeakMap();
7
+ function getTrackingMap(room) {
8
+ let map = tracked.get(room);
9
+ if (!map) {
10
+ map = /* @__PURE__ */ new Map();
11
+ tracked.set(room, map);
12
+ }
13
+ return map;
14
+ }
15
+ function resolveUserId(client) {
16
+ return client.userId ?? client.auth?.id;
17
+ }
18
+ async function trackUserSession(presence, userId, sessionId, entry) {
19
+ try {
20
+ await presence.hset(userRoomsKey(userId), sessionId, JSON.stringify(entry));
21
+ } catch {
22
+ }
23
+ }
24
+ async function releaseUserSession(presence, userId, sessionId) {
25
+ try {
26
+ await presence.hdel(userRoomsKey(userId), sessionId);
27
+ } catch {
28
+ }
29
+ }
30
+ function trackRoomJoin(room, client) {
31
+ if (!room.presence) {
32
+ return;
33
+ }
34
+ const userId = resolveUserId(client);
35
+ if (!userId) {
36
+ return;
37
+ }
38
+ getTrackingMap(room).set(client.sessionId, userId);
39
+ const entry = {
40
+ roomId: room.roomId,
41
+ roomName: room.roomName,
42
+ joinedAt: Date.now()
43
+ };
44
+ void trackUserSession(room.presence, userId, client.sessionId, entry);
45
+ }
46
+ function releaseRoomLeave(room, client) {
47
+ if (!room.presence) {
48
+ return;
49
+ }
50
+ const map = tracked.get(room);
51
+ const userId = map?.get(client.sessionId);
52
+ if (!userId || !map) {
53
+ return;
54
+ }
55
+ map.delete(client.sessionId);
56
+ void releaseUserSession(room.presence, userId, client.sessionId);
57
+ }
58
+ async function sweepRoomDispose(room) {
59
+ if (!room.presence) {
60
+ return;
61
+ }
62
+ const map = tracked.get(room);
63
+ if (!map || map.size === 0) {
64
+ return;
65
+ }
66
+ const pending = [];
67
+ for (const [sessionId, userId] of map) {
68
+ pending.push(releaseUserSession(room.presence, userId, sessionId));
69
+ }
70
+ map.clear();
71
+ await Promise.all(pending);
72
+ }
73
+ async function listUserSessions(presence, findRooms, userId, options = {}) {
74
+ const reconcile = options.reconcile === true;
75
+ const removeStale = reconcile && options.removeStale === true;
76
+ let raw;
77
+ try {
78
+ raw = await presence.hgetall(userRoomsKey(userId));
79
+ } catch {
80
+ return [];
81
+ }
82
+ const fields = Object.keys(raw);
83
+ if (fields.length === 0) {
84
+ return [];
85
+ }
86
+ const staleSessions = [];
87
+ const parsed = [];
88
+ for (const sessionId of fields) {
89
+ try {
90
+ parsed.push({ sessionId, entry: JSON.parse(raw[sessionId]) });
91
+ } catch {
92
+ staleSessions.push(sessionId);
93
+ }
94
+ }
95
+ if (!reconcile) {
96
+ return parsed.map(({ sessionId, entry }) => ({ sessionId, ...entry }));
97
+ }
98
+ const live = parsed.length > 0 ? await findRooms(parsed.map((p) => p.entry.roomId)) : /* @__PURE__ */ new Map();
99
+ const result = [];
100
+ for (const { sessionId, entry } of parsed) {
101
+ const room = live.get(entry.roomId);
102
+ if (!room) {
103
+ staleSessions.push(sessionId);
104
+ continue;
105
+ }
106
+ const info = { sessionId, ...entry };
107
+ if (room.processId !== void 0) {
108
+ info.processId = room.processId;
109
+ }
110
+ result.push(info);
111
+ }
112
+ if (removeStale && staleSessions.length > 0) {
113
+ const key = userRoomsKey(userId);
114
+ void Promise.all(
115
+ staleSessions.map((s) => presence.hdel(key, s))
116
+ ).catch(() => {
117
+ });
118
+ }
119
+ return result;
120
+ }
121
+ export {
122
+ USER_ROOMS_KEY_PREFIX,
123
+ listUserSessions,
124
+ releaseRoomLeave,
125
+ releaseUserSession,
126
+ sweepRoomDispose,
127
+ trackRoomJoin,
128
+ trackUserSession,
129
+ userRoomsKey
130
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/utils/UserSessionIndex.ts"],
4
+ "sourcesContent": ["/**\n * User \u2192 active sessions index: \"which active rooms is user X in?\"\n *\n * Without this, answering that question would require fanning out\n * `getInspectorView()` across every running room \u2014 O(rooms) per lookup.\n * Instead, each Room writes a small Presence hash entry on `_onJoin` and\n * removes it on `_onAfterLeave` / dispose. The admin endpoint reads the\n * hash with a single `hgetall`, then reconciles against the matchmaker's\n * live room listing to drop stale entries left behind by hard crashes\n * (no `onLeave` ran).\n *\n * Hash schema:\n * key: colyseus:user-rooms:{userId}\n * field: sessionId\n * value: JSON `{ roomId, roomName, joinedAt }` (joinedAt is unix ms)\n *\n * Anonymous clients (no userId resolvable) are skipped \u2014 the index is\n * for forensic / support workflows, not anonymous traffic. A Room can\n * also opt out wholesale by setting `trackUserSessions = false` (e.g.\n * a high-volume relay room that doesn't want to pay the Presence\n * write per join).\n *\n * Per-Room state (which sessionIds have an entry, and under which userId)\n * lives in a module-level WeakMap rather than as a field on Room itself.\n * Two reasons:\n *\n * 1. Room.ts stays thin \u2014 it only knows about three call points\n * (`trackRoomJoin`, `releaseRoomLeave`, `sweepRoomDispose`) and\n * doesn't have to carry an extra Map field or two private helper\n * methods solely for this concern.\n * 2. The WeakMap is GC-tied to the Room \u2014 when a Room instance is\n * collected, its entries vanish automatically. No explicit teardown\n * hook needed beyond the dispose sweep that drains Presence.\n *\n * @internal\n */\nimport type { Presence } from '../presence/Presence.ts';\n\nexport const USER_ROOMS_KEY_PREFIX = 'colyseus:user-rooms:';\n\nexport function userRoomsKey(userId: string): string {\n return USER_ROOMS_KEY_PREFIX + userId;\n}\n\n/**\n * What's serialized into the Presence hash value (sessionId is the\n * hash field key, not part of the body). Internal write-side shape.\n */\nexport interface UserRoomEntry {\n roomId: string;\n roomName: string;\n joinedAt: number;\n}\n\n/**\n * Public read-side shape returned by `listUserSessions`: a parsed\n * `UserRoomEntry` plus its sessionId, optionally enriched with\n * `processId` when reconcile against the matchmaker was on.\n */\nexport interface UserSessionInfo extends UserRoomEntry {\n sessionId: string;\n /**\n * Process hosting the room, per the matchmaker. Populated only\n * when `listUserSessions` was called with `reconcile: true` AND\n * the room is still in the matchmaker roster.\n */\n processId?: string;\n}\n\n/**\n * Structural subset of `Room` needed by the index. Lets this module\n * avoid importing `Room` (which would create a cycle) while still\n * staying typed at the call site.\n */\ninterface InspectorRoomShape {\n roomId: string;\n roomName: string;\n presence: Presence;\n}\n\n/**\n * Structural subset of a Client we read at join/leave. `userId` and\n * `auth` are both optional \u2014 the index simply skips clients without\n * either, which is the correct \"anonymous traffic doesn't show up\n * in support tooling\" behavior.\n */\ninterface InspectorClientShape {\n sessionId: string;\n userId?: string;\n auth?: { id?: string } | null;\n}\n\n/**\n * sessionId \u2192 userId for clients currently registered in the index,\n * scoped per Room. WeakMap-keyed so a forgotten Room takes its tracking\n * map with it.\n */\nconst tracked = new WeakMap<InspectorRoomShape, Map<string, string>>();\n\nfunction getTrackingMap(room: InspectorRoomShape): Map<string, string> {\n let map = tracked.get(room);\n if (!map) {\n map = new Map();\n tracked.set(room, map);\n }\n return map;\n}\n\nfunction resolveUserId(client: InspectorClientShape): string | undefined {\n return client.userId ?? client.auth?.id;\n}\n\n/**\n * Best-effort: write the join entry. Errors are swallowed because the\n * index is observability metadata \u2014 a Presence outage shouldn't reject\n * a player's join. Exposed as a pure helper for tests + the admin\n * endpoint; `trackRoomJoin` is the Room-flavored entrypoint.\n */\nexport async function trackUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n entry: UserRoomEntry,\n): Promise<void> {\n try {\n await presence.hset(userRoomsKey(userId), sessionId, JSON.stringify(entry));\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Best-effort: remove the join entry. Errors are swallowed so a Presence\n * blip doesn't bubble into `_onAfterLeave` / `_dispose`.\n */\nexport async function releaseUserSession(\n presence: Presence,\n userId: string,\n sessionId: string,\n): Promise<void> {\n try {\n await presence.hdel(userRoomsKey(userId), sessionId);\n } catch {\n // intentional: see fn-doc\n }\n}\n\n/**\n * Record `client`'s join under `room` in the reverse index. No-op for\n * clients with no resolvable userId (anonymous), and when the room\n * carries no presence (shouldn't happen \u2014 defensive for unit tests).\n *\n * Fire-and-forget against Presence; the in-memory tracking map updates\n * synchronously so a follow-up `releaseRoomLeave` always finds the\n * right userId even if the Presence write is still in flight.\n */\nexport function trackRoomJoin(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const userId = resolveUserId(client);\n if (!userId) { return; }\n getTrackingMap(room).set(client.sessionId, userId);\n const entry: UserRoomEntry = {\n roomId: room.roomId,\n roomName: room.roomName,\n joinedAt: Date.now(),\n };\n void trackUserSession(room.presence, userId, client.sessionId, entry);\n}\n\n/**\n * Drop `client`'s entry from the reverse index. Idempotent \u2014 no-op\n * when the client wasn't tracked (anonymous, or tracking failed at\n * join time).\n */\nexport function releaseRoomLeave(room: InspectorRoomShape, client: InspectorClientShape): void {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n const userId = map?.get(client.sessionId);\n if (!userId || !map) { return; }\n map.delete(client.sessionId);\n void releaseUserSession(room.presence, userId, client.sessionId);\n}\n\n/**\n * Sweep any still-tracked sessions for `room`. Called during dispose to\n * cover the case where `disconnect()` races the per-client `_onAfterLeave`\n * path \u2014 the read-side reconcile handles cross-process crash recovery,\n * but this is the cheap deterministic cleanup for a clean local dispose.\n *\n * Awaits the pending `hdel`s so the caller can sequence against \"the\n * index is now coherent\" \u2014 the dispose path uses that ordering.\n */\nexport async function sweepRoomDispose(room: InspectorRoomShape): Promise<void> {\n if (!room.presence) { return; }\n const map = tracked.get(room);\n if (!map || map.size === 0) { return; }\n const pending: Promise<void>[] = [];\n for (const [sessionId, userId] of map) {\n pending.push(releaseUserSession(room.presence, userId, sessionId));\n }\n map.clear();\n await Promise.all(pending);\n}\n\n/**\n * Minimal shape of a matchmaker room record needed for reconcile \u2014\n * keeps this module decoupled from the matchmaker / driver types.\n * `matchMaker.query()`'s actual return (`IRoomCache[]`) is structurally\n * a supertype of this, so callers can pass `matchMaker.query` directly.\n */\ninterface MatchmakerRoomLike {\n roomId: string;\n processId?: string;\n}\n\nexport interface ListUserSessionsOptions {\n /**\n * Drop entries whose `roomId` is no longer in the matchmaker roster\n * (the index can lag a crashed process). When `true`, the returned\n * entries also carry `processId` from the live room record.\n *\n * Off by default \u2014 most callers (kick everyone, count) don't need it\n * and the extra `matchMaker.query` round-trip isn't free.\n */\n reconcile?: boolean;\n\n /**\n * Fire-and-forget `hdel` for stale entries \u2014 those dropped by\n * reconcile (matchmaker doesn't know the room anymore) plus any\n * with corrupt JSON. Lets read endpoints self-heal the index on\n * each call. No-op when `reconcile` is `false`.\n */\n removeStale?: boolean;\n}\n\n/**\n * Read the user \u2192 active sessions index. Pure helper \u2014 the\n * `Presence` + matchmaker batch lookup are injected so this module\n * stays free of matchmaker imports (and so unit tests can drive it\n * with fake deps).\n *\n * Wire-op count per call:\n * - 1 HGETALL on the user's hash (always).\n * - 1 batch room lookup when `reconcile: true` AND there are\n * entries to verify; skipped otherwise.\n *\n * Bounded at 2 wire ops regardless of the user's session count.\n */\nexport async function listUserSessions(\n presence: Presence,\n findRooms: (roomIds: string[]) => Promise<Map<string, MatchmakerRoomLike>>,\n userId: string,\n options: ListUserSessionsOptions = {},\n): Promise<UserSessionInfo[]> {\n const reconcile = options.reconcile === true;\n const removeStale = reconcile && options.removeStale === true;\n\n let raw: Record<string, string>;\n try {\n raw = await presence.hgetall(userRoomsKey(userId));\n } catch {\n // Presence outage \u2014 observability shouldn't bring down the caller.\n return [];\n }\n const fields = Object.keys(raw);\n if (fields.length === 0) { return []; }\n\n const staleSessions: string[] = [];\n const parsed: Array<{ sessionId: string; entry: UserRoomEntry }> = [];\n for (const sessionId of fields) {\n try {\n parsed.push({ sessionId, entry: JSON.parse(raw[sessionId]) as UserRoomEntry });\n } catch {\n // Corrupt JSON \u2014 index drift. Removable even without reconcile.\n staleSessions.push(sessionId);\n }\n }\n\n if (!reconcile) {\n return parsed.map(({ sessionId, entry }) => ({ sessionId, ...entry }));\n }\n\n // One batch lookup for the K roomIds we care about. K = entries\n // surviving the JSON.parse stage, not cluster size.\n const live = parsed.length > 0\n ? await findRooms(parsed.map((p) => p.entry.roomId))\n : new Map<string, MatchmakerRoomLike>();\n\n const result: UserSessionInfo[] = [];\n for (const { sessionId, entry } of parsed) {\n const room = live.get(entry.roomId);\n if (!room) {\n // Matchmaker doesn't know this roomId anymore \u2014 stale entry\n // from a crashed process. Drop it (and remove if requested).\n staleSessions.push(sessionId);\n continue;\n }\n const info: UserSessionInfo = { sessionId, ...entry };\n if (room.processId !== undefined) { info.processId = room.processId; }\n result.push(info);\n }\n\n if (removeStale && staleSessions.length > 0) {\n const key = userRoomsKey(userId);\n void Promise.all(\n staleSessions.map((s) => presence.hdel(key, s)),\n ).catch(() => { /* presence outage, swallow */ });\n }\n\n return result;\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/core",
3
- "version": "0.17.43",
3
+ "version": "0.18.0",
4
4
  "description": "Multiplayer Framework for Node.js.",
5
5
  "type": "module",
6
6
  "input": "./src/index.ts",
@@ -46,31 +46,35 @@
46
46
  "node": ">= 22.x"
47
47
  },
48
48
  "dependencies": {
49
- "@colyseus/msgpackr": "^1.11.2",
49
+ "msgpackr": "^2.0.1",
50
50
  "@colyseus/timer": "^2.0.0",
51
51
  "@standard-schema/spec": "^1.0.0",
52
52
  "debug": "^4.3.4",
53
53
  "nanoid": "^3.3.11",
54
- "@colyseus/shared-types": "^0.17.6",
55
- "@colyseus/greeting-banner": "^3.0.8",
56
- "@colyseus/better-call": "^1.3.1"
54
+ "@colyseus/better-call": "^1.3.1",
55
+ "@colyseus/shared-types": "^0.18.0",
56
+ "@colyseus/greeting-banner": "^4.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@colyseus/schema": "^4.0.7",
59
+ "@colyseus/schema": "^5.0.3",
60
60
  "express": "^5.0.0",
61
- "@colyseus/redis-driver": "^0.17.7",
62
- "@colyseus/redis-presence": "^0.17.7",
63
- "@colyseus/tools": "^0.17.19"
61
+ "@colyseus/redis-driver": "^0.18.0",
62
+ "@colyseus/tools": "^0.18.0",
63
+ "@colyseus/redis-presence": "^0.18.0"
64
64
  },
65
65
  "peerDependencies": {
66
- "@colyseus/schema": "^4.0.7",
66
+ "@colyseus/schema": "^5.0.3",
67
67
  "@pm2/io": "^6.1.0",
68
68
  "express": "^4.16.0 || ^5.0.0",
69
69
  "zod": "^4.1.12",
70
70
  "@colyseus/better-call": "^1.3.1",
71
- "@colyseus/ws-transport": "^0.17.13"
71
+ "@colyseus/auth": "^0.18.0",
72
+ "@colyseus/ws-transport": "^0.18.0"
72
73
  },
73
74
  "peerDependenciesMeta": {
75
+ "@colyseus/auth": {
76
+ "optional": true
77
+ },
74
78
  "@colyseus/ws-transport": {
75
79
  "optional": true
76
80
  },
@@ -85,12 +89,13 @@
85
89
  }
86
90
  },
87
91
  "optionalPeerDependencies": {
88
- "@colyseus/tools": "0.17.x",
89
- "@colyseus/redis-driver": "0.17.x",
90
- "@colyseus/redis-presence": "0.17.x"
92
+ "@colyseus/tools": "0.18.x",
93
+ "@colyseus/redis-driver": "0.18.x",
94
+ "@colyseus/redis-presence": "0.18.x"
91
95
  },
92
96
  "publishConfig": {
93
- "access": "public"
97
+ "access": "public",
98
+ "tag": "next"
94
99
  },
95
100
  "gitHead": "c45b410e99eadffff4b74e701339992e2faa15f8"
96
101
  }
package/src/MatchMaker.ts CHANGED
@@ -321,6 +321,20 @@ export async function query<T extends Room = any>(
321
321
  return await driver.query<T>(conditions, sortOptions);
322
322
  }
323
323
 
324
+ /**
325
+ * Batch-resolve room caches by roomId in a single backend round
326
+ * trip. Returns a Map keyed by roomId; missing roomIds are absent.
327
+ *
328
+ * Hot paths (per-join uniqueness checks, by-user reverse-index
329
+ * lookups) reach for this instead of `query({})` — the latter scans
330
+ * the whole room cache and grows linearly with cluster size, while
331
+ * `findRoomsByIds` is O(K) in the caller's input.
332
+ */
333
+ export async function findRoomsByIds(roomIds: string[]): Promise<Map<string, IRoomCache>> {
334
+ if (roomIds.length === 0) { return new Map(); }
335
+ return await driver.findByIds(roomIds);
336
+ }
337
+
324
338
  /**
325
339
  * Find for a public and unlocked room available.
326
340
  *
@@ -939,13 +953,33 @@ export function buildSeatReservation(room: IRoomCache, sessionId: string) {
939
953
 
940
954
  async function callOnAuth(roomName: string, clientOptions?: ClientOptions, authContext?: AuthContext) {
941
955
  const roomClass = getRoomClass(roomName);
942
- if (roomClass && roomClass['onAuth'] && roomClass['onAuth'] !== Room['onAuth']) {
943
- const result = await roomClass['onAuth'](authContext.token, clientOptions, authContext)
944
- if (!result) {
945
- throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');
946
- }
947
- return result;
956
+ const onAuth = roomClass?.['onAuth'];
957
+ if (!onAuth) { return; }
958
+
959
+ // Server-initiated joins (e.g. internal `matchMaker.create()` from
960
+ // bots/tests/cron) skip auth — there's no transport, no headers, no
961
+ // token. Treat the absence of authContext as "no auth performed",
962
+ // which leaves client.auth undefined just like the legacy short-
963
+ // circuit did.
964
+ if (!authContext) { return; }
965
+
966
+ // Always invoke onAuth when an auth context is present — even when
967
+ // the room hasn't overridden it. The base `Room.onAuth` returns
968
+ // `true` (or whatever a side-effect import like @colyseus/auth swapped
969
+ // it for), and we use the return value to populate `client.auth` when
970
+ // it's a payload object. Pre-change this call was skipped when the
971
+ // subclass inherited the default, which made it impossible to install
972
+ // a workspace-wide token decoder by patching `Room.onAuth` once.
973
+ const result = await onAuth(authContext.token, clientOptions, authContext);
974
+
975
+ // Auth-result semantics:
976
+ // false / null / undefined → AUTH_FAILED
977
+ // true → succeeded but no payload (don't set client.auth)
978
+ // anything else (object) → succeeded; the value becomes client.auth
979
+ if (result === false || result === null || result === undefined) {
980
+ throw new ServerError(ErrorCode.AUTH_FAILED, 'onAuth failed');
948
981
  }
982
+ return result === true ? undefined : result;
949
983
  }
950
984
 
951
985
  /**