colyseus 0.17.10 → 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.
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // bundles/colyseus/src/plugins/idle-kick.ts
21
+ var idle_kick_exports = {};
22
+ __export(idle_kick_exports, {
23
+ IdleKickPlugin: () => IdleKickPlugin
24
+ });
25
+ module.exports = __toCommonJS(idle_kick_exports);
26
+ var import_core = require("@colyseus/core");
27
+ var IdleKickPlugin = class extends import_core.RoomPlugin {
28
+ constructor(opts) {
29
+ super();
30
+ this.pluginName = "idleKick";
31
+ this.timeoutMs = opts.timeoutMs;
32
+ this.scanIntervalMs = opts.scanIntervalMs ?? Math.min(opts.timeoutMs / 4, 5e3);
33
+ this.closeCode = opts.closeCode ?? 1e3;
34
+ this.reason = opts.reason ?? "kicked";
35
+ this.isExempt = opts.isExempt;
36
+ this.onKickCb = opts.onKick;
37
+ }
38
+ onCreate() {
39
+ this.interval = this.room.clock.setInterval(() => this.scan(), this.scanIntervalMs);
40
+ }
41
+ onJoin(client) {
42
+ client._lastMessageTime = this.room.clock.currentTime;
43
+ }
44
+ onDispose() {
45
+ this.interval?.clear();
46
+ this.interval = void 0;
47
+ }
48
+ scan() {
49
+ const now = this.room.clock.currentTime;
50
+ const cutoff = now - this.timeoutMs;
51
+ for (let i = this.room.clients.length - 1; i >= 0; i--) {
52
+ const c = this.room.clients[i];
53
+ if (this.isExempt?.(c)) {
54
+ continue;
55
+ }
56
+ const last = c._lastMessageTime;
57
+ if (last <= cutoff) {
58
+ this.onKickCb?.(c, now - last);
59
+ this.room.kickClient(c.sessionId, this.closeCode, this.reason);
60
+ }
61
+ }
62
+ }
63
+ };
64
+ // Annotate the CommonJS export names for ESM import in node:
65
+ 0 && (module.exports = {
66
+ IdleKickPlugin
67
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/idle-kick.ts"],
4
+ "sourcesContent": ["/**\n * IdleKickPlugin \u2014 auto-disconnects clients that haven't sent a message\n * in a configurable amount of time.\n *\n * Usage:\n *\n * import { IdleKickPlugin } from 'colyseus/plugins/idle-kick';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * idle: new IdleKickPlugin({ timeoutMs: 60_000 }),\n * });\n * }\n *\n * Footprint: one `clock.setInterval` per room, zero per-message work and\n * zero per-client state. Activity is read from `client._lastMessageTime`,\n * which the room already maintains for rate-limiting. Any inbound frame\n * (including SDK keepalive PINGs) refreshes it \u2014 by design.\n *\n * On kick, clients see WS close code 1000 (\"normal closure\") with the\n * reason `'kicked'` by default. The SDK treats 1000 as a final leave\n * (no auto-reconnect attempt) and forwards the reason string to\n * `room.onLeave((code, reason) => ...)`.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\n\nexport interface IdleKickOptions {\n /** Milliseconds of inactivity after which a client is kicked. */\n timeoutMs: number;\n\n /**\n * Milliseconds between scans. Default: `min(timeoutMs / 4, 5000)`.\n * Smaller values kick sooner past the deadline; larger values reduce\n * timer wakeups. The scan itself is O(clients) with no allocations.\n */\n scanIntervalMs?: number;\n\n /** WS close code sent to the kicked client. Default: 1000. */\n closeCode?: number;\n\n /** WS close reason forwarded to the SDK's `onLeave`. Default: `'kicked'`. */\n reason?: string;\n\n /**\n * Optional predicate to exempt a client from being kicked (e.g.,\n * admins, spectators, currently-AFK-but-by-design). Return `true` to\n * keep the client immune.\n */\n isExempt?: (client: Client) => boolean;\n\n /**\n * Called just before a client is kicked. Use to log, broadcast a\n * \"<player> went idle\" message, or persist analytics.\n */\n onKick?: (client: Client, idleMs: number) => void;\n}\n\nexport class IdleKickPlugin extends RoomPlugin {\n readonly pluginName = 'idleKick' as const;\n\n private timeoutMs: number;\n private scanIntervalMs: number;\n private closeCode: number;\n private reason: string;\n private isExempt?: (client: Client) => boolean;\n private onKickCb?: (client: Client, idleMs: number) => void;\n\n private interval?: { clear: () => void };\n\n constructor(opts: IdleKickOptions) {\n super();\n this.timeoutMs = opts.timeoutMs;\n this.scanIntervalMs = opts.scanIntervalMs ?? Math.min(opts.timeoutMs / 4, 5000);\n this.closeCode = opts.closeCode ?? 1000;\n this.reason = opts.reason ?? 'kicked';\n this.isExempt = opts.isExempt;\n this.onKickCb = opts.onKick;\n }\n\n protected onCreate() {\n this.interval = this.room.clock.setInterval(() => this.scan(), this.scanIntervalMs);\n }\n\n protected onJoin(client: Client) {\n // Seed `_lastMessageTime` so a client that joins and immediately goes\n // silent has a meaningful \"last seen\" timestamp. Without this it's 0\n // until their first inbound frame, which would look like \"infinitely\n // idle\" to the scan.\n (client as any)._lastMessageTime = this.room.clock.currentTime;\n }\n\n protected onDispose() {\n this.interval?.clear();\n this.interval = undefined;\n }\n\n private scan() {\n const now = this.room.clock.currentTime;\n const cutoff = now - this.timeoutMs;\n // Iterate in reverse \u2014 `kickClient` removes the client synchronously\n // from `this.room.clients` via `_forciblyCloseClient`, so going\n // backwards keeps the index valid.\n for (let i = this.room.clients.length - 1; i >= 0; i--) {\n const c = this.room.clients[i] as Client & { _lastMessageTime: number };\n if (this.isExempt?.(c)) { continue; }\n const last = c._lastMessageTime;\n if (last <= cutoff) {\n this.onKickCb?.(c, now - last);\n this.room.kickClient(c.sessionId, this.closeCode, this.reason);\n }\n }\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAwBA,kBAAwC;AAiCjC,IAAM,iBAAN,cAA6B,uBAAW;AAAA,EAY7C,YAAY,MAAuB;AACjC,UAAM;AAZR,SAAS,aAAa;AAapB,SAAK,YAAY,KAAK;AACtB,SAAK,iBAAiB,KAAK,kBAAkB,KAAK,IAAI,KAAK,YAAY,GAAG,GAAI;AAC9E,SAAK,YAAY,KAAK,aAAa;AACnC,SAAK,SAAS,KAAK,UAAU;AAC7B,SAAK,WAAW,KAAK;AACrB,SAAK,WAAW,KAAK;AAAA,EACvB;AAAA,EAEU,WAAW;AACnB,SAAK,WAAW,KAAK,KAAK,MAAM,YAAY,MAAM,KAAK,KAAK,GAAG,KAAK,cAAc;AAAA,EACpF;AAAA,EAEU,OAAO,QAAgB;AAK/B,IAAC,OAAe,mBAAmB,KAAK,KAAK,MAAM;AAAA,EACrD;AAAA,EAEU,YAAY;AACpB,SAAK,UAAU,MAAM;AACrB,SAAK,WAAW;AAAA,EAClB;AAAA,EAEQ,OAAO;AACb,UAAM,MAAM,KAAK,KAAK,MAAM;AAC5B,UAAM,SAAS,MAAM,KAAK;AAI1B,aAAS,IAAI,KAAK,KAAK,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;AACtD,YAAM,IAAI,KAAK,KAAK,QAAQ,CAAC;AAC7B,UAAI,KAAK,WAAW,CAAC,GAAG;AAAE;AAAA,MAAU;AACpC,YAAM,OAAO,EAAE;AACf,UAAI,QAAQ,QAAQ;AAClB,aAAK,WAAW,GAAG,MAAM,IAAI;AAC7B,aAAK,KAAK,WAAW,EAAE,WAAW,KAAK,WAAW,KAAK,MAAM;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * IdleKickPlugin — auto-disconnects clients that haven't sent a message
3
+ * in a configurable amount of time.
4
+ *
5
+ * Usage:
6
+ *
7
+ * import { IdleKickPlugin } from 'colyseus/plugins/idle-kick';
8
+ *
9
+ * class GameRoom extends Room {
10
+ * plugins = definePlugins({
11
+ * idle: new IdleKickPlugin({ timeoutMs: 60_000 }),
12
+ * });
13
+ * }
14
+ *
15
+ * Footprint: one `clock.setInterval` per room, zero per-message work and
16
+ * zero per-client state. Activity is read from `client._lastMessageTime`,
17
+ * which the room already maintains for rate-limiting. Any inbound frame
18
+ * (including SDK keepalive PINGs) refreshes it — by design.
19
+ *
20
+ * On kick, clients see WS close code 1000 ("normal closure") with the
21
+ * reason `'kicked'` by default. The SDK treats 1000 as a final leave
22
+ * (no auto-reconnect attempt) and forwards the reason string to
23
+ * `room.onLeave((code, reason) => ...)`.
24
+ */
25
+ import { RoomPlugin, type Client } from '@colyseus/core';
26
+ export interface IdleKickOptions {
27
+ /** Milliseconds of inactivity after which a client is kicked. */
28
+ timeoutMs: number;
29
+ /**
30
+ * Milliseconds between scans. Default: `min(timeoutMs / 4, 5000)`.
31
+ * Smaller values kick sooner past the deadline; larger values reduce
32
+ * timer wakeups. The scan itself is O(clients) with no allocations.
33
+ */
34
+ scanIntervalMs?: number;
35
+ /** WS close code sent to the kicked client. Default: 1000. */
36
+ closeCode?: number;
37
+ /** WS close reason forwarded to the SDK's `onLeave`. Default: `'kicked'`. */
38
+ reason?: string;
39
+ /**
40
+ * Optional predicate to exempt a client from being kicked (e.g.,
41
+ * admins, spectators, currently-AFK-but-by-design). Return `true` to
42
+ * keep the client immune.
43
+ */
44
+ isExempt?: (client: Client) => boolean;
45
+ /**
46
+ * Called just before a client is kicked. Use to log, broadcast a
47
+ * "<player> went idle" message, or persist analytics.
48
+ */
49
+ onKick?: (client: Client, idleMs: number) => void;
50
+ }
51
+ export declare class IdleKickPlugin extends RoomPlugin {
52
+ readonly pluginName: "idleKick";
53
+ private timeoutMs;
54
+ private scanIntervalMs;
55
+ private closeCode;
56
+ private reason;
57
+ private isExempt?;
58
+ private onKickCb?;
59
+ private interval?;
60
+ constructor(opts: IdleKickOptions);
61
+ protected onCreate(): void;
62
+ protected onJoin(client: Client): void;
63
+ protected onDispose(): void;
64
+ private scan;
65
+ }
@@ -0,0 +1,42 @@
1
+ // bundles/colyseus/src/plugins/idle-kick.ts
2
+ import { RoomPlugin } from "@colyseus/core";
3
+ var IdleKickPlugin = class extends RoomPlugin {
4
+ constructor(opts) {
5
+ super();
6
+ this.pluginName = "idleKick";
7
+ this.timeoutMs = opts.timeoutMs;
8
+ this.scanIntervalMs = opts.scanIntervalMs ?? Math.min(opts.timeoutMs / 4, 5e3);
9
+ this.closeCode = opts.closeCode ?? 1e3;
10
+ this.reason = opts.reason ?? "kicked";
11
+ this.isExempt = opts.isExempt;
12
+ this.onKickCb = opts.onKick;
13
+ }
14
+ onCreate() {
15
+ this.interval = this.room.clock.setInterval(() => this.scan(), this.scanIntervalMs);
16
+ }
17
+ onJoin(client) {
18
+ client._lastMessageTime = this.room.clock.currentTime;
19
+ }
20
+ onDispose() {
21
+ this.interval?.clear();
22
+ this.interval = void 0;
23
+ }
24
+ scan() {
25
+ const now = this.room.clock.currentTime;
26
+ const cutoff = now - this.timeoutMs;
27
+ for (let i = this.room.clients.length - 1; i >= 0; i--) {
28
+ const c = this.room.clients[i];
29
+ if (this.isExempt?.(c)) {
30
+ continue;
31
+ }
32
+ const last = c._lastMessageTime;
33
+ if (last <= cutoff) {
34
+ this.onKickCb?.(c, now - last);
35
+ this.room.kickClient(c.sessionId, this.closeCode, this.reason);
36
+ }
37
+ }
38
+ }
39
+ };
40
+ export {
41
+ IdleKickPlugin
42
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/idle-kick.ts"],
4
+ "sourcesContent": ["/**\n * IdleKickPlugin \u2014 auto-disconnects clients that haven't sent a message\n * in a configurable amount of time.\n *\n * Usage:\n *\n * import { IdleKickPlugin } from 'colyseus/plugins/idle-kick';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * idle: new IdleKickPlugin({ timeoutMs: 60_000 }),\n * });\n * }\n *\n * Footprint: one `clock.setInterval` per room, zero per-message work and\n * zero per-client state. Activity is read from `client._lastMessageTime`,\n * which the room already maintains for rate-limiting. Any inbound frame\n * (including SDK keepalive PINGs) refreshes it \u2014 by design.\n *\n * On kick, clients see WS close code 1000 (\"normal closure\") with the\n * reason `'kicked'` by default. The SDK treats 1000 as a final leave\n * (no auto-reconnect attempt) and forwards the reason string to\n * `room.onLeave((code, reason) => ...)`.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\n\nexport interface IdleKickOptions {\n /** Milliseconds of inactivity after which a client is kicked. */\n timeoutMs: number;\n\n /**\n * Milliseconds between scans. Default: `min(timeoutMs / 4, 5000)`.\n * Smaller values kick sooner past the deadline; larger values reduce\n * timer wakeups. The scan itself is O(clients) with no allocations.\n */\n scanIntervalMs?: number;\n\n /** WS close code sent to the kicked client. Default: 1000. */\n closeCode?: number;\n\n /** WS close reason forwarded to the SDK's `onLeave`. Default: `'kicked'`. */\n reason?: string;\n\n /**\n * Optional predicate to exempt a client from being kicked (e.g.,\n * admins, spectators, currently-AFK-but-by-design). Return `true` to\n * keep the client immune.\n */\n isExempt?: (client: Client) => boolean;\n\n /**\n * Called just before a client is kicked. Use to log, broadcast a\n * \"<player> went idle\" message, or persist analytics.\n */\n onKick?: (client: Client, idleMs: number) => void;\n}\n\nexport class IdleKickPlugin extends RoomPlugin {\n readonly pluginName = 'idleKick' as const;\n\n private timeoutMs: number;\n private scanIntervalMs: number;\n private closeCode: number;\n private reason: string;\n private isExempt?: (client: Client) => boolean;\n private onKickCb?: (client: Client, idleMs: number) => void;\n\n private interval?: { clear: () => void };\n\n constructor(opts: IdleKickOptions) {\n super();\n this.timeoutMs = opts.timeoutMs;\n this.scanIntervalMs = opts.scanIntervalMs ?? Math.min(opts.timeoutMs / 4, 5000);\n this.closeCode = opts.closeCode ?? 1000;\n this.reason = opts.reason ?? 'kicked';\n this.isExempt = opts.isExempt;\n this.onKickCb = opts.onKick;\n }\n\n protected onCreate() {\n this.interval = this.room.clock.setInterval(() => this.scan(), this.scanIntervalMs);\n }\n\n protected onJoin(client: Client) {\n // Seed `_lastMessageTime` so a client that joins and immediately goes\n // silent has a meaningful \"last seen\" timestamp. Without this it's 0\n // until their first inbound frame, which would look like \"infinitely\n // idle\" to the scan.\n (client as any)._lastMessageTime = this.room.clock.currentTime;\n }\n\n protected onDispose() {\n this.interval?.clear();\n this.interval = undefined;\n }\n\n private scan() {\n const now = this.room.clock.currentTime;\n const cutoff = now - this.timeoutMs;\n // Iterate in reverse \u2014 `kickClient` removes the client synchronously\n // from `this.room.clients` via `_forciblyCloseClient`, so going\n // backwards keeps the index valid.\n for (let i = this.room.clients.length - 1; i >= 0; i--) {\n const c = this.room.clients[i] as Client & { _lastMessageTime: number };\n if (this.isExempt?.(c)) { continue; }\n const last = c._lastMessageTime;\n if (last <= cutoff) {\n this.onKickCb?.(c, now - last);\n this.room.kickClient(c.sessionId, this.closeCode, this.reason);\n }\n }\n }\n}\n"],
5
+ "mappings": ";AAwBA,SAAS,kBAA+B;AAiCjC,IAAM,iBAAN,cAA6B,WAAW;AAAA,EAY7C,YAAY,MAAuB;AACjC,UAAM;AAZR,SAAS,aAAa;AAapB,SAAK,YAAY,KAAK;AACtB,SAAK,iBAAiB,KAAK,kBAAkB,KAAK,IAAI,KAAK,YAAY,GAAG,GAAI;AAC9E,SAAK,YAAY,KAAK,aAAa;AACnC,SAAK,SAAS,KAAK,UAAU;AAC7B,SAAK,WAAW,KAAK;AACrB,SAAK,WAAW,KAAK;AAAA,EACvB;AAAA,EAEU,WAAW;AACnB,SAAK,WAAW,KAAK,KAAK,MAAM,YAAY,MAAM,KAAK,KAAK,GAAG,KAAK,cAAc;AAAA,EACpF;AAAA,EAEU,OAAO,QAAgB;AAK/B,IAAC,OAAe,mBAAmB,KAAK,KAAK,MAAM;AAAA,EACrD;AAAA,EAEU,YAAY;AACpB,SAAK,UAAU,MAAM;AACrB,SAAK,WAAW;AAAA,EAClB;AAAA,EAEQ,OAAO;AACb,UAAM,MAAM,KAAK,KAAK,MAAM;AAC5B,UAAM,SAAS,MAAM,KAAK;AAI1B,aAAS,IAAI,KAAK,KAAK,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;AACtD,YAAM,IAAI,KAAK,KAAK,QAAQ,CAAC;AAC7B,UAAI,KAAK,WAAW,CAAC,GAAG;AAAE;AAAA,MAAU;AACpC,YAAM,OAAO,EAAE;AACf,UAAI,QAAQ,QAAQ;AAClB,aAAK,WAAW,GAAG,MAAM,IAAI;AAC7B,aAAK,KAAK,WAAW,EAAE,WAAW,KAAK,WAAW,KAAK,MAAM;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // bundles/colyseus/src/plugins/track-user-sessions.ts
21
+ var track_user_sessions_exports = {};
22
+ __export(track_user_sessions_exports, {
23
+ TrackUserSessionsPlugin: () => TrackUserSessionsPlugin
24
+ });
25
+ module.exports = __toCommonJS(track_user_sessions_exports);
26
+ var import_core = require("@colyseus/core");
27
+ var import_internal = require("@colyseus/core/internal");
28
+ var TrackUserSessionsPlugin = class extends import_core.RoomPlugin {
29
+ constructor() {
30
+ super(...arguments);
31
+ this.pluginName = "trackUserSessions";
32
+ // Match the old in-Room call sites: `trackRoomJoin` used to fire
33
+ // AFTER the user's `onJoin` finished, so we keep the same point
34
+ // in the lifecycle here (after-room). `onLeave` and `onDispose`
35
+ // already default to after-room — no override needed.
36
+ this.order = { onJoin: "after" };
37
+ }
38
+ onJoin(client) {
39
+ (0, import_internal.trackRoomJoin)(this.room, client);
40
+ }
41
+ onLeave(client) {
42
+ (0, import_internal.releaseRoomLeave)(this.room, client);
43
+ }
44
+ onDispose() {
45
+ return (0, import_internal.sweepRoomDispose)(this.room);
46
+ }
47
+ /**
48
+ * Cluster-wide lookup: which active sessions does this user have?
49
+ *
50
+ * Reads from the same Presence reverse index this plugin populates.
51
+ * Returns one `UserSessionInfo` per session (a user can be in
52
+ * multiple rooms / multiple processes concurrently).
53
+ *
54
+ * By default this is a fast raw read — corrupt JSON entries are
55
+ * dropped silently, but entries that point at rooms which have
56
+ * since vanished (crashed process) are still returned. Pass
57
+ * `reconcile: true` to cross-check against `matchMaker.query` and
58
+ * drop those stale entries; the surviving entries also carry
59
+ * `processId` from the live room record.
60
+ *
61
+ * Pass `removeStale: true` (only honored with `reconcile: true`)
62
+ * to fire-and-forget `hdel` the dropped entries so the index
63
+ * self-heals over time.
64
+ *
65
+ * Anonymous clients are not indexed, so an anonymous-only user
66
+ * returns an empty array.
67
+ */
68
+ static listUserSessions(userId, options) {
69
+ return (0, import_internal.listUserSessionsLive)(userId, options);
70
+ }
71
+ };
72
+ // Annotate the CommonJS export names for ESM import in node:
73
+ 0 && (module.exports = {
74
+ TrackUserSessionsPlugin
75
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/track-user-sessions.ts"],
4
+ "sourcesContent": ["/**\n * TrackUserSessionsPlugin \u2014 maintains the per-user reverse index in\n * Presence that powers operator tooling (admin's by-user inspector,\n * `closeUserSessions`, the `UniqueSessionPlugin`'s duplicate check,\n * any other consumer asking \"which rooms is user X in right now?\").\n *\n * Usage \u2014 explicit:\n *\n * import { TrackUserSessionsPlugin } from 'colyseus/plugins/track-user-sessions';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * track: new TrackUserSessionsPlugin(),\n * });\n * }\n *\n * Usage \u2014 implicit (recommended): you don't install it yourself.\n * Plugins that need the index \u2014 like `UniqueSessionPlugin` \u2014 declare\n * it as a `static dependencies` entry, and the framework\n * auto-instantiates it at room construction. The plugin is\n * stateless and zero-config, so the auto-include is safe.\n *\n * Reading the index from your own code: call the static\n * `TrackUserSessionsPlugin.listUserSessions(userId)` \u2014 see its JSDoc\n * for the reconcile / sweep options.\n *\n * Ordering: `onJoin` runs AFTER the room's own `onJoin` so the\n * index reflects clients that survived the user-defined join hook\n * (rejected joins never make it into the index). `onLeave` and\n * `onDispose` use the default plugins-after-room order.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\nimport {\n trackRoomJoin, releaseRoomLeave, sweepRoomDispose,\n listUserSessionsLive, type UserSessionInfo, type ListUserSessionsOptions,\n} from '@colyseus/core/internal';\n\nexport type { UserSessionInfo, ListUserSessionsOptions };\n\nexport class TrackUserSessionsPlugin extends RoomPlugin {\n readonly pluginName = 'trackUserSessions' as const;\n\n // Match the old in-Room call sites: `trackRoomJoin` used to fire\n // AFTER the user's `onJoin` finished, so we keep the same point\n // in the lifecycle here (after-room). `onLeave` and `onDispose`\n // already default to after-room \u2014 no override needed.\n protected order = { onJoin: 'after' as const };\n\n protected onJoin(client: Client) { trackRoomJoin(this.room, client); }\n protected onLeave(client: Client) { releaseRoomLeave(this.room, client); }\n protected onDispose() { return sweepRoomDispose(this.room); }\n\n /**\n * Cluster-wide lookup: which active sessions does this user have?\n *\n * Reads from the same Presence reverse index this plugin populates.\n * Returns one `UserSessionInfo` per session (a user can be in\n * multiple rooms / multiple processes concurrently).\n *\n * By default this is a fast raw read \u2014 corrupt JSON entries are\n * dropped silently, but entries that point at rooms which have\n * since vanished (crashed process) are still returned. Pass\n * `reconcile: true` to cross-check against `matchMaker.query` and\n * drop those stale entries; the surviving entries also carry\n * `processId` from the live room record.\n *\n * Pass `removeStale: true` (only honored with `reconcile: true`)\n * to fire-and-forget `hdel` the dropped entries so the index\n * self-heals over time.\n *\n * Anonymous clients are not indexed, so an anonymous-only user\n * returns an empty array.\n */\n static listUserSessions(\n userId: string,\n options?: ListUserSessionsOptions,\n ): Promise<UserSessionInfo[]> {\n return listUserSessionsLive(userId, options);\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA+BA,kBAAwC;AACxC,sBAGO;AAIA,IAAM,0BAAN,cAAsC,uBAAW;AAAA,EAAjD;AAAA;AACL,SAAS,aAAa;AAMtB;AAAA;AAAA;AAAA;AAAA,SAAU,QAAQ,EAAE,QAAQ,QAAiB;AAAA;AAAA,EAEnC,OAAO,QAAgB;AAAE,uCAAc,KAAK,MAAM,MAAM;AAAA,EAAG;AAAA,EAC3D,QAAQ,QAAgB;AAAE,0CAAiB,KAAK,MAAM,MAAM;AAAA,EAAG;AAAA,EAC/D,YAAY;AAAE,eAAO,kCAAiB,KAAK,IAAI;AAAA,EAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAuB5D,OAAO,iBACL,QACA,SAC4B;AAC5B,eAAO,sCAAqB,QAAQ,OAAO;AAAA,EAC7C;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * TrackUserSessionsPlugin — maintains the per-user reverse index in
3
+ * Presence that powers operator tooling (admin's by-user inspector,
4
+ * `closeUserSessions`, the `UniqueSessionPlugin`'s duplicate check,
5
+ * any other consumer asking "which rooms is user X in right now?").
6
+ *
7
+ * Usage — explicit:
8
+ *
9
+ * import { TrackUserSessionsPlugin } from 'colyseus/plugins/track-user-sessions';
10
+ *
11
+ * class GameRoom extends Room {
12
+ * plugins = definePlugins({
13
+ * track: new TrackUserSessionsPlugin(),
14
+ * });
15
+ * }
16
+ *
17
+ * Usage — implicit (recommended): you don't install it yourself.
18
+ * Plugins that need the index — like `UniqueSessionPlugin` — declare
19
+ * it as a `static dependencies` entry, and the framework
20
+ * auto-instantiates it at room construction. The plugin is
21
+ * stateless and zero-config, so the auto-include is safe.
22
+ *
23
+ * Reading the index from your own code: call the static
24
+ * `TrackUserSessionsPlugin.listUserSessions(userId)` — see its JSDoc
25
+ * for the reconcile / sweep options.
26
+ *
27
+ * Ordering: `onJoin` runs AFTER the room's own `onJoin` so the
28
+ * index reflects clients that survived the user-defined join hook
29
+ * (rejected joins never make it into the index). `onLeave` and
30
+ * `onDispose` use the default plugins-after-room order.
31
+ */
32
+ import { RoomPlugin, type Client } from '@colyseus/core';
33
+ import { type UserSessionInfo, type ListUserSessionsOptions } from '@colyseus/core/internal';
34
+ export type { UserSessionInfo, ListUserSessionsOptions };
35
+ export declare class TrackUserSessionsPlugin extends RoomPlugin {
36
+ readonly pluginName: "trackUserSessions";
37
+ protected order: {
38
+ onJoin: "after";
39
+ };
40
+ protected onJoin(client: Client): void;
41
+ protected onLeave(client: Client): void;
42
+ protected onDispose(): Promise<void>;
43
+ /**
44
+ * Cluster-wide lookup: which active sessions does this user have?
45
+ *
46
+ * Reads from the same Presence reverse index this plugin populates.
47
+ * Returns one `UserSessionInfo` per session (a user can be in
48
+ * multiple rooms / multiple processes concurrently).
49
+ *
50
+ * By default this is a fast raw read — corrupt JSON entries are
51
+ * dropped silently, but entries that point at rooms which have
52
+ * since vanished (crashed process) are still returned. Pass
53
+ * `reconcile: true` to cross-check against `matchMaker.query` and
54
+ * drop those stale entries; the surviving entries also carry
55
+ * `processId` from the live room record.
56
+ *
57
+ * Pass `removeStale: true` (only honored with `reconcile: true`)
58
+ * to fire-and-forget `hdel` the dropped entries so the index
59
+ * self-heals over time.
60
+ *
61
+ * Anonymous clients are not indexed, so an anonymous-only user
62
+ * returns an empty array.
63
+ */
64
+ static listUserSessions(userId: string, options?: ListUserSessionsOptions): Promise<UserSessionInfo[]>;
65
+ }
@@ -0,0 +1,55 @@
1
+ // bundles/colyseus/src/plugins/track-user-sessions.ts
2
+ import { RoomPlugin } from "@colyseus/core";
3
+ import {
4
+ trackRoomJoin,
5
+ releaseRoomLeave,
6
+ sweepRoomDispose,
7
+ listUserSessionsLive
8
+ } from "@colyseus/core/internal";
9
+ var TrackUserSessionsPlugin = class extends RoomPlugin {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.pluginName = "trackUserSessions";
13
+ // Match the old in-Room call sites: `trackRoomJoin` used to fire
14
+ // AFTER the user's `onJoin` finished, so we keep the same point
15
+ // in the lifecycle here (after-room). `onLeave` and `onDispose`
16
+ // already default to after-room — no override needed.
17
+ this.order = { onJoin: "after" };
18
+ }
19
+ onJoin(client) {
20
+ trackRoomJoin(this.room, client);
21
+ }
22
+ onLeave(client) {
23
+ releaseRoomLeave(this.room, client);
24
+ }
25
+ onDispose() {
26
+ return sweepRoomDispose(this.room);
27
+ }
28
+ /**
29
+ * Cluster-wide lookup: which active sessions does this user have?
30
+ *
31
+ * Reads from the same Presence reverse index this plugin populates.
32
+ * Returns one `UserSessionInfo` per session (a user can be in
33
+ * multiple rooms / multiple processes concurrently).
34
+ *
35
+ * By default this is a fast raw read — corrupt JSON entries are
36
+ * dropped silently, but entries that point at rooms which have
37
+ * since vanished (crashed process) are still returned. Pass
38
+ * `reconcile: true` to cross-check against `matchMaker.query` and
39
+ * drop those stale entries; the surviving entries also carry
40
+ * `processId` from the live room record.
41
+ *
42
+ * Pass `removeStale: true` (only honored with `reconcile: true`)
43
+ * to fire-and-forget `hdel` the dropped entries so the index
44
+ * self-heals over time.
45
+ *
46
+ * Anonymous clients are not indexed, so an anonymous-only user
47
+ * returns an empty array.
48
+ */
49
+ static listUserSessions(userId, options) {
50
+ return listUserSessionsLive(userId, options);
51
+ }
52
+ };
53
+ export {
54
+ TrackUserSessionsPlugin
55
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/track-user-sessions.ts"],
4
+ "sourcesContent": ["/**\n * TrackUserSessionsPlugin \u2014 maintains the per-user reverse index in\n * Presence that powers operator tooling (admin's by-user inspector,\n * `closeUserSessions`, the `UniqueSessionPlugin`'s duplicate check,\n * any other consumer asking \"which rooms is user X in right now?\").\n *\n * Usage \u2014 explicit:\n *\n * import { TrackUserSessionsPlugin } from 'colyseus/plugins/track-user-sessions';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * track: new TrackUserSessionsPlugin(),\n * });\n * }\n *\n * Usage \u2014 implicit (recommended): you don't install it yourself.\n * Plugins that need the index \u2014 like `UniqueSessionPlugin` \u2014 declare\n * it as a `static dependencies` entry, and the framework\n * auto-instantiates it at room construction. The plugin is\n * stateless and zero-config, so the auto-include is safe.\n *\n * Reading the index from your own code: call the static\n * `TrackUserSessionsPlugin.listUserSessions(userId)` \u2014 see its JSDoc\n * for the reconcile / sweep options.\n *\n * Ordering: `onJoin` runs AFTER the room's own `onJoin` so the\n * index reflects clients that survived the user-defined join hook\n * (rejected joins never make it into the index). `onLeave` and\n * `onDispose` use the default plugins-after-room order.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\nimport {\n trackRoomJoin, releaseRoomLeave, sweepRoomDispose,\n listUserSessionsLive, type UserSessionInfo, type ListUserSessionsOptions,\n} from '@colyseus/core/internal';\n\nexport type { UserSessionInfo, ListUserSessionsOptions };\n\nexport class TrackUserSessionsPlugin extends RoomPlugin {\n readonly pluginName = 'trackUserSessions' as const;\n\n // Match the old in-Room call sites: `trackRoomJoin` used to fire\n // AFTER the user's `onJoin` finished, so we keep the same point\n // in the lifecycle here (after-room). `onLeave` and `onDispose`\n // already default to after-room \u2014 no override needed.\n protected order = { onJoin: 'after' as const };\n\n protected onJoin(client: Client) { trackRoomJoin(this.room, client); }\n protected onLeave(client: Client) { releaseRoomLeave(this.room, client); }\n protected onDispose() { return sweepRoomDispose(this.room); }\n\n /**\n * Cluster-wide lookup: which active sessions does this user have?\n *\n * Reads from the same Presence reverse index this plugin populates.\n * Returns one `UserSessionInfo` per session (a user can be in\n * multiple rooms / multiple processes concurrently).\n *\n * By default this is a fast raw read \u2014 corrupt JSON entries are\n * dropped silently, but entries that point at rooms which have\n * since vanished (crashed process) are still returned. Pass\n * `reconcile: true` to cross-check against `matchMaker.query` and\n * drop those stale entries; the surviving entries also carry\n * `processId` from the live room record.\n *\n * Pass `removeStale: true` (only honored with `reconcile: true`)\n * to fire-and-forget `hdel` the dropped entries so the index\n * self-heals over time.\n *\n * Anonymous clients are not indexed, so an anonymous-only user\n * returns an empty array.\n */\n static listUserSessions(\n userId: string,\n options?: ListUserSessionsOptions,\n ): Promise<UserSessionInfo[]> {\n return listUserSessionsLive(userId, options);\n }\n}\n"],
5
+ "mappings": ";AA+BA,SAAS,kBAA+B;AACxC;AAAA,EACE;AAAA,EAAe;AAAA,EAAkB;AAAA,EACjC;AAAA,OACK;AAIA,IAAM,0BAAN,cAAsC,WAAW;AAAA,EAAjD;AAAA;AACL,SAAS,aAAa;AAMtB;AAAA;AAAA;AAAA;AAAA,SAAU,QAAQ,EAAE,QAAQ,QAAiB;AAAA;AAAA,EAEnC,OAAO,QAAgB;AAAE,kBAAc,KAAK,MAAM,MAAM;AAAA,EAAG;AAAA,EAC3D,QAAQ,QAAgB;AAAE,qBAAiB,KAAK,MAAM,MAAM;AAAA,EAAG;AAAA,EAC/D,YAAY;AAAE,WAAO,iBAAiB,KAAK,IAAI;AAAA,EAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAuB5D,OAAO,iBACL,QACA,SAC4B;AAC5B,WAAO,qBAAqB,QAAQ,OAAO;AAAA,EAC7C;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // bundles/colyseus/src/plugins/unique-session.ts
21
+ var unique_session_exports = {};
22
+ __export(unique_session_exports, {
23
+ UniqueSessionPlugin: () => UniqueSessionPlugin
24
+ });
25
+ module.exports = __toCommonJS(unique_session_exports);
26
+ var import_core = require("@colyseus/core");
27
+ var import_internal = require("@colyseus/core/internal");
28
+ var import_track_user_sessions = require("./track-user-sessions.cjs");
29
+ function defaultResolveUserId(client) {
30
+ const auth = client.auth;
31
+ if (!auth) {
32
+ return void 0;
33
+ }
34
+ if (typeof auth.id === "string" && auth.id.length > 0) {
35
+ return auth.id;
36
+ }
37
+ if (typeof auth.userId === "string" && auth.userId.length > 0) {
38
+ return auth.userId;
39
+ }
40
+ return void 0;
41
+ }
42
+ var UniqueSessionPlugin = class extends import_core.RoomPlugin {
43
+ constructor(opts = {}) {
44
+ super();
45
+ this.pluginName = "uniqueSession";
46
+ this.max = opts.max ?? 1;
47
+ this.mode = opts.onDuplicate ?? "reject";
48
+ this.rejectCode = opts.rejectCode ?? 4400;
49
+ this.rejectMessage = opts.rejectMessage ?? "already_in_room";
50
+ this.conflictsWith = opts.conflictsWith;
51
+ this.resolveUserId = opts.resolveUserId ?? defaultResolveUserId;
52
+ }
53
+ static {
54
+ this.dependencies = [import_track_user_sessions.TrackUserSessionsPlugin];
55
+ }
56
+ async onJoin(client) {
57
+ const userId = this.resolveUserId(client);
58
+ if (!userId) {
59
+ return;
60
+ }
61
+ const { conflicts, staleSessions } = await this.evaluate(userId);
62
+ if (staleSessions.length > 0) {
63
+ const key = (0, import_internal.userRoomsKey)(userId);
64
+ void Promise.all(
65
+ staleSessions.map((s) => import_core.matchMaker.presence.hdel(key, s))
66
+ ).catch(() => {
67
+ });
68
+ }
69
+ if (conflicts.length < this.max) {
70
+ return;
71
+ }
72
+ if (this.mode === "replace") {
73
+ const sorted = conflicts.slice().sort((a, b) => a.joinedAt - b.joinedAt);
74
+ const toKick = sorted.slice(0, conflicts.length - this.max + 1);
75
+ await Promise.all(
76
+ toKick.map(
77
+ (info) => import_core.matchMaker.remoteRoomCall(
78
+ info.roomId,
79
+ "kickClient",
80
+ [info.sessionId, 1e3, "replaced"]
81
+ ).catch(() => {
82
+ })
83
+ )
84
+ );
85
+ return;
86
+ }
87
+ throw new import_core.ServerError(this.rejectCode, this.rejectMessage);
88
+ }
89
+ /**
90
+ * Read the user's session index, parse + reconcile entries against
91
+ * live rooms, return the conflict list. Exposed so unit tests can
92
+ * exercise the parsing/reconciliation logic without standing up
93
+ * the full plugin lifecycle.
94
+ *
95
+ * Wire-op cap: 2 per call — one HGETALL for the user's hash, plus
96
+ * one batch lookup for cross-room candidates (skipped when none).
97
+ * Self-room entries are resolved against `this.room.clients`
98
+ * locally and never touch the matchmaker.
99
+ */
100
+ async evaluate(userId) {
101
+ const raw = await import_core.matchMaker.presence.hgetall((0, import_internal.userRoomsKey)(userId));
102
+ const fields = Object.keys(raw);
103
+ if (fields.length === 0) {
104
+ return { conflicts: [], staleSessions: [] };
105
+ }
106
+ const staleSessions = [];
107
+ const selfRoomConflicts = [];
108
+ const crossRoomEntries = [];
109
+ for (const sessionId of fields) {
110
+ let entry;
111
+ try {
112
+ entry = JSON.parse(raw[sessionId]);
113
+ } catch {
114
+ staleSessions.push(sessionId);
115
+ continue;
116
+ }
117
+ if (entry.roomName !== this.room.roomName) {
118
+ continue;
119
+ }
120
+ if (entry.roomId === this.room.roomId) {
121
+ const stillHere = this.room.clients?.some(
122
+ (c) => c.sessionId === sessionId
123
+ ) ?? false;
124
+ if (stillHere) {
125
+ selfRoomConflicts.push({ sessionId, ...entry });
126
+ } else {
127
+ staleSessions.push(sessionId);
128
+ }
129
+ } else {
130
+ crossRoomEntries.push({ sessionId, entry });
131
+ }
132
+ }
133
+ const live = crossRoomEntries.length > 0 ? await import_core.matchMaker.findRoomsByIds(crossRoomEntries.map((c) => c.entry.roomId)) : /* @__PURE__ */ new Map();
134
+ const conflicts = [...selfRoomConflicts];
135
+ for (const { sessionId, entry } of crossRoomEntries) {
136
+ const room = live.get(entry.roomId);
137
+ if (!room) {
138
+ staleSessions.push(sessionId);
139
+ continue;
140
+ }
141
+ const info = { sessionId, ...entry };
142
+ if (room.processId !== void 0) {
143
+ info.processId = room.processId;
144
+ }
145
+ conflicts.push(info);
146
+ }
147
+ const filtered = this.conflictsWith ? conflicts.filter((c) => this.conflictsWith({
148
+ sessionId: c.sessionId,
149
+ joinedAt: c.joinedAt,
150
+ room: live.get(c.roomId)
151
+ }, this.room)) : conflicts;
152
+ return { conflicts: filtered, staleSessions };
153
+ }
154
+ };
155
+ // Annotate the CommonJS export names for ESM import in node:
156
+ 0 && (module.exports = {
157
+ UniqueSessionPlugin
158
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/unique-session.ts"],
4
+ "sourcesContent": ["/**\n * UniqueSessionPlugin \u2014 enforces \"one (or N) concurrent session(s) of\n * this room type per user.\" Use to prevent a player from opening\n * two tabs of the same game mode, or from re-queueing into another\n * instance while their old session is still active.\n *\n * Usage:\n *\n * import { UniqueSessionPlugin } from 'colyseus/plugins/unique-session';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * unique: new UniqueSessionPlugin({ max: 1, onDuplicate: 'reject' }),\n * });\n * }\n *\n * The plugin reads the per-user reverse index that\n * `TrackUserSessionsPlugin` maintains in Presence. Same source the\n * admin's by-user inspector and the `closeUserSessions` helper use \u2014\n * no new bookkeeping. (For a generic lookup from your own code,\n * prefer `TrackUserSessionsPlugin.listUserSessions(userId)`.)\n *\n * Anonymous clients (no `userId`) are NEVER rejected: with no stable\n * identity we have nothing to enforce against. Document this so\n * apps that anonymously match-make don't expect a guarantee they\n * can't get.\n *\n * Race window: two near-simultaneous joins from the same user can\n * both pass the check before either has written to the hash\n * (`trackRoomJoin` fires AFTER the plugin/room onJoin chain). For\n * the singleton/two-tabs UX this is acceptable; if your game needs\n * strict serialization, layer an atomic `hsetnx` reservation on\n * top \u2014 out of scope for v1.\n *\n * Stale-entry handling: entries are reconciled two ways. Entries\n * pointing at another room get dropped if `matchMaker.query` doesn't\n * know that room anymore (crashed process). Entries pointing at THIS\n * room get cross-checked against `this.room.clients` \u2014 if the\n * sessionId isn't live locally, the entry is treated as stale (e.g.\n * a fast disconnect that missed `onLeave`) and dropped, so a\n * legitimate fresh join isn't blocked by ghost data.\n *\n * Auto-includes `TrackUserSessionsPlugin` via `static dependencies`\n * so the per-user reverse index this plugin reads from is always\n * populated \u2014 apps don't have to register the tracker plugin\n * themselves.\n */\nimport {\n RoomPlugin, ServerError, matchMaker,\n type Client, type Room, type IRoomCache, type PluginDependencies,\n} from '@colyseus/core';\nimport { userRoomsKey, type UserRoomEntry, type UserSessionInfo } from '@colyseus/core/internal';\nimport { TrackUserSessionsPlugin } from './track-user-sessions.ts';\n\n/**\n * Per-existing-session context passed to the `conflictsWith`\n * predicate. The full matchmaker `IRoomCache` (including `metadata`)\n * is attached for cross-room entries \u2014 populated from the same\n * batch lookup the plugin already runs, so no extra wire ops.\n *\n * For entries that point at the CURRENT room (same `Room` instance\n * the plugin is attached to), `room` is `undefined` \u2014 read from\n * the second arg (`currentRoom`) instead, which is the live `Room`.\n */\nexport interface UniqueSessionConflict {\n /** The existing session's id. */\n sessionId: string;\n /** Unix ms timestamp recorded when the existing session joined. */\n joinedAt: number;\n /** Matchmaker's cached `IRoomCache` for the existing session's\n * room \u2014 `metadata`, `clients`, `locked`, etc. Undefined when the\n * existing session is in the same room instance as the joining\n * client; use `currentRoom` for that case. */\n room?: IRoomCache;\n}\n\nexport interface UniqueSessionOptions {\n /**\n * Max concurrent sessions of this room type per user. Default `1`\n * (strict singleton). Setting `> 1` lets users open e.g. two tabs\n * but not ten \u2014 useful for testing/spectating workflows.\n */\n max?: number;\n\n /**\n * Behavior when the limit is exceeded:\n * - `'reject'` (default): refuse the new join with a `ServerError`.\n * - `'replace'`: kick the oldest existing session(s) so the new\n * join can proceed. Useful for refresh-style UX where a player\n * expects their newer tab to \"win\".\n */\n onDuplicate?: 'reject' | 'replace';\n\n /**\n * Error code attached to the `ServerError` thrown on reject. The\n * SDK surfaces this as `MatchMakeError.code`. Default `4400`\n * (matches the 4xxx custom range Colyseus uses elsewhere).\n */\n rejectCode?: number;\n\n /**\n * Message attached to the `ServerError` thrown on reject. Default\n * `'already_in_room'`. The SDK exposes it as `e.message` so the\n * client can branch on it.\n */\n rejectMessage?: string;\n\n /**\n * Per-existing-session filter applied after the plugin's own\n * same-`roomName` matching + stale-entry reconcile, but before\n * counting against `max`. Return `true` to count the existing\n * session as a conflict, `false` to skip it.\n *\n * Receives:\n * - `existing` \u2014 `{ sessionId, joinedAt, room? }`. The `room`\n * field is the matchmaker's cached `IRoomCache` for the\n * existing session's room (carrying `metadata`, `clients`,\n * `locked`, etc.), populated for cross-room entries from the\n * same batch lookup the plugin already runs. It is `undefined`\n * when the existing session is in the SAME `Room` instance as\n * the joining client \u2014 for those, read state from `currentRoom`.\n * - `currentRoom` \u2014 the live `Room` instance the join is targeting\n * (i.e. `this.room` inside the plugin).\n *\n * Use this for metadata-based scoping (e.g. \"only count\n * same-game-mode sessions\"), per-process exemptions (multi-region\n * deploys), capacity-aware filtering (\"don't count rooms that are\n * already full\"), etc.\n */\n conflictsWith?: (\n existing: UniqueSessionConflict,\n currentRoom: Room,\n ) => boolean;\n\n /**\n * Extract the user's stable id from `Client`. Return a non-empty\n * string when the client carries an identity; return `undefined`\n * (or empty string) for anonymous clients \u2014 the plugin skips the\n * check in that case.\n *\n * Default reads `client.auth.id`, then `client.auth.userId` \u2014 the\n * JWT payload shape `@colyseus/auth`'s default `onAuth` produces\n * for both authenticated AND anonymously-registered sessions\n * (anonymous users from `registerAnonymous` get an `id` too;\n * `anonymousId` is a separate upgrade-token field).\n *\n * Custom auth flows that store the id elsewhere (e.g.\n * `client.auth.profile.sub` for raw OIDC) override with a\n * function that returns from that path.\n */\n resolveUserId?: (client: Client) => string | undefined;\n}\n\n/** Default extractor \u2014 `auth.id`, then `auth.userId`. */\nfunction defaultResolveUserId(client: Client): string | undefined {\n const auth = (client as any).auth;\n if (!auth) { return undefined; }\n if (typeof auth.id === 'string' && auth.id.length > 0) { return auth.id; }\n if (typeof auth.userId === 'string' && auth.userId.length > 0) { return auth.userId; }\n return undefined;\n}\n\n/**\n * Result of evaluating the user's existing sessions. Extracted from\n * the `onJoin` body so the unit tests can drive it in isolation.\n */\ninterface ConflictResult {\n /** Entries that should count against the limit. */\n conflicts: UserSessionInfo[];\n /** Session ids that point at rooms which no longer exist \u2014\n * best-effort hdel'd so the next check doesn't re-pay the cost. */\n staleSessions: string[];\n}\n\nexport class UniqueSessionPlugin extends RoomPlugin {\n readonly pluginName = 'uniqueSession' as const;\n\n static dependencies: PluginDependencies = [TrackUserSessionsPlugin];\n\n private max: number;\n private mode: 'reject' | 'replace';\n private rejectCode: number;\n private rejectMessage: string;\n private conflictsWith?: (\n existing: UniqueSessionConflict,\n currentRoom: Room,\n ) => boolean;\n private resolveUserId: (client: Client) => string | undefined;\n\n constructor(opts: UniqueSessionOptions = {}) {\n super();\n this.max = opts.max ?? 1;\n this.mode = opts.onDuplicate ?? 'reject';\n this.rejectCode = opts.rejectCode ?? 4400;\n this.rejectMessage = opts.rejectMessage ?? 'already_in_room';\n this.conflictsWith = opts.conflictsWith;\n this.resolveUserId = opts.resolveUserId ?? defaultResolveUserId;\n }\n\n protected async onJoin(client: Client): Promise<void> {\n const userId = this.resolveUserId(client);\n // Anonymous clients have no stable identity \u2014 nothing to enforce\n // against. Skipping (rather than rejecting) keeps anonymous\n // matchmaking working unchanged.\n if (!userId) { return; }\n\n const { conflicts, staleSessions } = await this.evaluate(userId);\n\n // Best-effort cleanup of stale entries \u2014 don't block the response\n // on it (the user-visible answer doesn't depend on hdel landing).\n if (staleSessions.length > 0) {\n const key = userRoomsKey(userId);\n void Promise.all(\n staleSessions.map((s) => matchMaker.presence.hdel(key, s)),\n ).catch(() => { /* ignore */ });\n }\n\n if (conflicts.length < this.max) { return; }\n\n if (this.mode === 'replace') {\n // Kick the *oldest* conflicts first so the newest existing\n // session survives alongside the new one (or the new one wins\n // outright when max=1). Sort by joinedAt ascending \u2014 earliest\n // join time is oldest.\n const sorted = conflicts.slice().sort((a, b) => a.joinedAt - b.joinedAt);\n const toKick = sorted.slice(0, conflicts.length - this.max + 1);\n await Promise.all(\n toKick.map((info) =>\n matchMaker.remoteRoomCall(\n info.roomId,\n 'kickClient' as any,\n [info.sessionId, 1000, 'replaced'],\n ).catch(() => { /* room gone \u2014 that's fine, the conflict resolves either way */ }),\n ),\n );\n return;\n }\n\n // 'reject' \u2014 throw so the framework refuses the join. The thrown\n // ServerError surfaces to the SDK as `MatchMakeError`.\n throw new ServerError(this.rejectCode, this.rejectMessage);\n }\n\n /**\n * Read the user's session index, parse + reconcile entries against\n * live rooms, return the conflict list. Exposed so unit tests can\n * exercise the parsing/reconciliation logic without standing up\n * the full plugin lifecycle.\n *\n * Wire-op cap: 2 per call \u2014 one HGETALL for the user's hash, plus\n * one batch lookup for cross-room candidates (skipped when none).\n * Self-room entries are resolved against `this.room.clients`\n * locally and never touch the matchmaker.\n */\n async evaluate(userId: string): Promise<ConflictResult> {\n // Wire op 1: read user's session hash.\n const raw = await matchMaker.presence.hgetall(userRoomsKey(userId));\n const fields = Object.keys(raw);\n if (fields.length === 0) { return { conflicts: [], staleSessions: [] }; }\n\n const staleSessions: string[] = [];\n const selfRoomConflicts: UserSessionInfo[] = [];\n const crossRoomEntries: Array<{ sessionId: string; entry: UserRoomEntry }> = [];\n\n // Stage 1: classify entries in-memory. No matchmaker calls.\n // - corrupt JSON \u2192 stale\n // - wrong roomName \u2192 ignored\n // - this.room.roomId \u2192 resolved via local clients\n // - cross-room \u2192 deferred to the batch lookup below\n for (const sessionId of fields) {\n let entry: UserRoomEntry;\n try { entry = JSON.parse(raw[sessionId]); }\n catch {\n staleSessions.push(sessionId);\n continue;\n }\n\n if (entry.roomName !== this.room.roomName) { continue; }\n\n if (entry.roomId === this.room.roomId) {\n // Local clients are the authoritative source for \"is this\n // sessionId still active in our room?\" \u2014 an entry without a\n // live client is a leftover from a connection whose onLeave\n // never ran (TCP reset, fast disconnect). Drop it so a\n // legitimate fresh join isn't rejected by ghost data.\n const stillHere = this.room.clients?.some(\n (c: any) => c.sessionId === sessionId,\n ) ?? false;\n if (stillHere) {\n selfRoomConflicts.push({ sessionId, ...entry });\n } else {\n staleSessions.push(sessionId);\n }\n } else {\n crossRoomEntries.push({ sessionId, entry });\n }\n }\n\n // Wire op 2 (skipped when no cross-room candidates): one batch\n // lookup verifies that each cross-room candidate's roomId still\n // exists in the matchmaker. Bounded at K cross-room entries for\n // *this* user \u2014 never the cluster size.\n const live = crossRoomEntries.length > 0\n ? await matchMaker.findRoomsByIds(crossRoomEntries.map((c) => c.entry.roomId))\n : new Map();\n\n const conflicts: UserSessionInfo[] = [...selfRoomConflicts];\n for (const { sessionId, entry } of crossRoomEntries) {\n const room = live.get(entry.roomId);\n if (!room) {\n // Cross-room entry pointing at a roomId the matchmaker no\n // longer knows about \u2014 index drift from a crashed process.\n staleSessions.push(sessionId);\n continue;\n }\n const info: UserSessionInfo = { sessionId, ...entry };\n if (room.processId !== undefined) { info.processId = room.processId; }\n conflicts.push(info);\n }\n\n // `live.get(c.roomId)` is undefined for self-room conflicts (we\n // never put them in `live`) and the cross-room `IRoomCache` for\n // the rest \u2014 exactly the predicate's contract.\n const filtered = this.conflictsWith\n ? conflicts.filter((c) => this.conflictsWith!({\n sessionId: c.sessionId,\n joinedAt: c.joinedAt,\n room: live.get(c.roomId),\n }, this.room))\n : conflicts;\n return { conflicts: filtered, staleSessions };\n }\n\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }