colyseus 0.17.9 → 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,174 @@
1
+ /**
2
+ * UniqueSessionPlugin — enforces "one (or N) concurrent session(s) of
3
+ * this room type per user." Use to prevent a player from opening
4
+ * two tabs of the same game mode, or from re-queueing into another
5
+ * instance while their old session is still active.
6
+ *
7
+ * Usage:
8
+ *
9
+ * import { UniqueSessionPlugin } from 'colyseus/plugins/unique-session';
10
+ *
11
+ * class GameRoom extends Room {
12
+ * plugins = definePlugins({
13
+ * unique: new UniqueSessionPlugin({ max: 1, onDuplicate: 'reject' }),
14
+ * });
15
+ * }
16
+ *
17
+ * The plugin reads the per-user reverse index that
18
+ * `TrackUserSessionsPlugin` maintains in Presence. Same source the
19
+ * admin's by-user inspector and the `closeUserSessions` helper use —
20
+ * no new bookkeeping. (For a generic lookup from your own code,
21
+ * prefer `TrackUserSessionsPlugin.listUserSessions(userId)`.)
22
+ *
23
+ * Anonymous clients (no `userId`) are NEVER rejected: with no stable
24
+ * identity we have nothing to enforce against. Document this so
25
+ * apps that anonymously match-make don't expect a guarantee they
26
+ * can't get.
27
+ *
28
+ * Race window: two near-simultaneous joins from the same user can
29
+ * both pass the check before either has written to the hash
30
+ * (`trackRoomJoin` fires AFTER the plugin/room onJoin chain). For
31
+ * the singleton/two-tabs UX this is acceptable; if your game needs
32
+ * strict serialization, layer an atomic `hsetnx` reservation on
33
+ * top — out of scope for v1.
34
+ *
35
+ * Stale-entry handling: entries are reconciled two ways. Entries
36
+ * pointing at another room get dropped if `matchMaker.query` doesn't
37
+ * know that room anymore (crashed process). Entries pointing at THIS
38
+ * room get cross-checked against `this.room.clients` — if the
39
+ * sessionId isn't live locally, the entry is treated as stale (e.g.
40
+ * a fast disconnect that missed `onLeave`) and dropped, so a
41
+ * legitimate fresh join isn't blocked by ghost data.
42
+ *
43
+ * Auto-includes `TrackUserSessionsPlugin` via `static dependencies`
44
+ * so the per-user reverse index this plugin reads from is always
45
+ * populated — apps don't have to register the tracker plugin
46
+ * themselves.
47
+ */
48
+ import { RoomPlugin, type Client, type Room, type IRoomCache, type PluginDependencies } from '@colyseus/core';
49
+ import { type UserSessionInfo } from '@colyseus/core/internal';
50
+ /**
51
+ * Per-existing-session context passed to the `conflictsWith`
52
+ * predicate. The full matchmaker `IRoomCache` (including `metadata`)
53
+ * is attached for cross-room entries — populated from the same
54
+ * batch lookup the plugin already runs, so no extra wire ops.
55
+ *
56
+ * For entries that point at the CURRENT room (same `Room` instance
57
+ * the plugin is attached to), `room` is `undefined` — read from
58
+ * the second arg (`currentRoom`) instead, which is the live `Room`.
59
+ */
60
+ export interface UniqueSessionConflict {
61
+ /** The existing session's id. */
62
+ sessionId: string;
63
+ /** Unix ms timestamp recorded when the existing session joined. */
64
+ joinedAt: number;
65
+ /** Matchmaker's cached `IRoomCache` for the existing session's
66
+ * room — `metadata`, `clients`, `locked`, etc. Undefined when the
67
+ * existing session is in the same room instance as the joining
68
+ * client; use `currentRoom` for that case. */
69
+ room?: IRoomCache;
70
+ }
71
+ export interface UniqueSessionOptions {
72
+ /**
73
+ * Max concurrent sessions of this room type per user. Default `1`
74
+ * (strict singleton). Setting `> 1` lets users open e.g. two tabs
75
+ * but not ten — useful for testing/spectating workflows.
76
+ */
77
+ max?: number;
78
+ /**
79
+ * Behavior when the limit is exceeded:
80
+ * - `'reject'` (default): refuse the new join with a `ServerError`.
81
+ * - `'replace'`: kick the oldest existing session(s) so the new
82
+ * join can proceed. Useful for refresh-style UX where a player
83
+ * expects their newer tab to "win".
84
+ */
85
+ onDuplicate?: 'reject' | 'replace';
86
+ /**
87
+ * Error code attached to the `ServerError` thrown on reject. The
88
+ * SDK surfaces this as `MatchMakeError.code`. Default `4400`
89
+ * (matches the 4xxx custom range Colyseus uses elsewhere).
90
+ */
91
+ rejectCode?: number;
92
+ /**
93
+ * Message attached to the `ServerError` thrown on reject. Default
94
+ * `'already_in_room'`. The SDK exposes it as `e.message` so the
95
+ * client can branch on it.
96
+ */
97
+ rejectMessage?: string;
98
+ /**
99
+ * Per-existing-session filter applied after the plugin's own
100
+ * same-`roomName` matching + stale-entry reconcile, but before
101
+ * counting against `max`. Return `true` to count the existing
102
+ * session as a conflict, `false` to skip it.
103
+ *
104
+ * Receives:
105
+ * - `existing` — `{ sessionId, joinedAt, room? }`. The `room`
106
+ * field is the matchmaker's cached `IRoomCache` for the
107
+ * existing session's room (carrying `metadata`, `clients`,
108
+ * `locked`, etc.), populated for cross-room entries from the
109
+ * same batch lookup the plugin already runs. It is `undefined`
110
+ * when the existing session is in the SAME `Room` instance as
111
+ * the joining client — for those, read state from `currentRoom`.
112
+ * - `currentRoom` — the live `Room` instance the join is targeting
113
+ * (i.e. `this.room` inside the plugin).
114
+ *
115
+ * Use this for metadata-based scoping (e.g. "only count
116
+ * same-game-mode sessions"), per-process exemptions (multi-region
117
+ * deploys), capacity-aware filtering ("don't count rooms that are
118
+ * already full"), etc.
119
+ */
120
+ conflictsWith?: (existing: UniqueSessionConflict, currentRoom: Room) => boolean;
121
+ /**
122
+ * Extract the user's stable id from `Client`. Return a non-empty
123
+ * string when the client carries an identity; return `undefined`
124
+ * (or empty string) for anonymous clients — the plugin skips the
125
+ * check in that case.
126
+ *
127
+ * Default reads `client.auth.id`, then `client.auth.userId` — the
128
+ * JWT payload shape `@colyseus/auth`'s default `onAuth` produces
129
+ * for both authenticated AND anonymously-registered sessions
130
+ * (anonymous users from `registerAnonymous` get an `id` too;
131
+ * `anonymousId` is a separate upgrade-token field).
132
+ *
133
+ * Custom auth flows that store the id elsewhere (e.g.
134
+ * `client.auth.profile.sub` for raw OIDC) override with a
135
+ * function that returns from that path.
136
+ */
137
+ resolveUserId?: (client: Client) => string | undefined;
138
+ }
139
+ /**
140
+ * Result of evaluating the user's existing sessions. Extracted from
141
+ * the `onJoin` body so the unit tests can drive it in isolation.
142
+ */
143
+ interface ConflictResult {
144
+ /** Entries that should count against the limit. */
145
+ conflicts: UserSessionInfo[];
146
+ /** Session ids that point at rooms which no longer exist —
147
+ * best-effort hdel'd so the next check doesn't re-pay the cost. */
148
+ staleSessions: string[];
149
+ }
150
+ export declare class UniqueSessionPlugin extends RoomPlugin {
151
+ readonly pluginName: "uniqueSession";
152
+ static dependencies: PluginDependencies;
153
+ private max;
154
+ private mode;
155
+ private rejectCode;
156
+ private rejectMessage;
157
+ private conflictsWith?;
158
+ private resolveUserId;
159
+ constructor(opts?: UniqueSessionOptions);
160
+ protected onJoin(client: Client): Promise<void>;
161
+ /**
162
+ * Read the user's session index, parse + reconcile entries against
163
+ * live rooms, return the conflict list. Exposed so unit tests can
164
+ * exercise the parsing/reconciliation logic without standing up
165
+ * the full plugin lifecycle.
166
+ *
167
+ * Wire-op cap: 2 per call — one HGETALL for the user's hash, plus
168
+ * one batch lookup for cross-room candidates (skipped when none).
169
+ * Self-room entries are resolved against `this.room.clients`
170
+ * locally and never touch the matchmaker.
171
+ */
172
+ evaluate(userId: string): Promise<ConflictResult>;
173
+ }
174
+ export {};
@@ -0,0 +1,137 @@
1
+ // bundles/colyseus/src/plugins/unique-session.ts
2
+ import {
3
+ RoomPlugin,
4
+ ServerError,
5
+ matchMaker
6
+ } from "@colyseus/core";
7
+ import { userRoomsKey } from "@colyseus/core/internal";
8
+ import { TrackUserSessionsPlugin } from "./track-user-sessions.mjs";
9
+ function defaultResolveUserId(client) {
10
+ const auth = client.auth;
11
+ if (!auth) {
12
+ return void 0;
13
+ }
14
+ if (typeof auth.id === "string" && auth.id.length > 0) {
15
+ return auth.id;
16
+ }
17
+ if (typeof auth.userId === "string" && auth.userId.length > 0) {
18
+ return auth.userId;
19
+ }
20
+ return void 0;
21
+ }
22
+ var UniqueSessionPlugin = class extends RoomPlugin {
23
+ constructor(opts = {}) {
24
+ super();
25
+ this.pluginName = "uniqueSession";
26
+ this.max = opts.max ?? 1;
27
+ this.mode = opts.onDuplicate ?? "reject";
28
+ this.rejectCode = opts.rejectCode ?? 4400;
29
+ this.rejectMessage = opts.rejectMessage ?? "already_in_room";
30
+ this.conflictsWith = opts.conflictsWith;
31
+ this.resolveUserId = opts.resolveUserId ?? defaultResolveUserId;
32
+ }
33
+ static {
34
+ this.dependencies = [TrackUserSessionsPlugin];
35
+ }
36
+ async onJoin(client) {
37
+ const userId = this.resolveUserId(client);
38
+ if (!userId) {
39
+ return;
40
+ }
41
+ const { conflicts, staleSessions } = await this.evaluate(userId);
42
+ if (staleSessions.length > 0) {
43
+ const key = userRoomsKey(userId);
44
+ void Promise.all(
45
+ staleSessions.map((s) => matchMaker.presence.hdel(key, s))
46
+ ).catch(() => {
47
+ });
48
+ }
49
+ if (conflicts.length < this.max) {
50
+ return;
51
+ }
52
+ if (this.mode === "replace") {
53
+ const sorted = conflicts.slice().sort((a, b) => a.joinedAt - b.joinedAt);
54
+ const toKick = sorted.slice(0, conflicts.length - this.max + 1);
55
+ await Promise.all(
56
+ toKick.map(
57
+ (info) => matchMaker.remoteRoomCall(
58
+ info.roomId,
59
+ "kickClient",
60
+ [info.sessionId, 1e3, "replaced"]
61
+ ).catch(() => {
62
+ })
63
+ )
64
+ );
65
+ return;
66
+ }
67
+ throw new ServerError(this.rejectCode, this.rejectMessage);
68
+ }
69
+ /**
70
+ * Read the user's session index, parse + reconcile entries against
71
+ * live rooms, return the conflict list. Exposed so unit tests can
72
+ * exercise the parsing/reconciliation logic without standing up
73
+ * the full plugin lifecycle.
74
+ *
75
+ * Wire-op cap: 2 per call — one HGETALL for the user's hash, plus
76
+ * one batch lookup for cross-room candidates (skipped when none).
77
+ * Self-room entries are resolved against `this.room.clients`
78
+ * locally and never touch the matchmaker.
79
+ */
80
+ async evaluate(userId) {
81
+ const raw = await matchMaker.presence.hgetall(userRoomsKey(userId));
82
+ const fields = Object.keys(raw);
83
+ if (fields.length === 0) {
84
+ return { conflicts: [], staleSessions: [] };
85
+ }
86
+ const staleSessions = [];
87
+ const selfRoomConflicts = [];
88
+ const crossRoomEntries = [];
89
+ for (const sessionId of fields) {
90
+ let entry;
91
+ try {
92
+ entry = JSON.parse(raw[sessionId]);
93
+ } catch {
94
+ staleSessions.push(sessionId);
95
+ continue;
96
+ }
97
+ if (entry.roomName !== this.room.roomName) {
98
+ continue;
99
+ }
100
+ if (entry.roomId === this.room.roomId) {
101
+ const stillHere = this.room.clients?.some(
102
+ (c) => c.sessionId === sessionId
103
+ ) ?? false;
104
+ if (stillHere) {
105
+ selfRoomConflicts.push({ sessionId, ...entry });
106
+ } else {
107
+ staleSessions.push(sessionId);
108
+ }
109
+ } else {
110
+ crossRoomEntries.push({ sessionId, entry });
111
+ }
112
+ }
113
+ const live = crossRoomEntries.length > 0 ? await matchMaker.findRoomsByIds(crossRoomEntries.map((c) => c.entry.roomId)) : /* @__PURE__ */ new Map();
114
+ const conflicts = [...selfRoomConflicts];
115
+ for (const { sessionId, entry } of crossRoomEntries) {
116
+ const room = live.get(entry.roomId);
117
+ if (!room) {
118
+ staleSessions.push(sessionId);
119
+ continue;
120
+ }
121
+ const info = { sessionId, ...entry };
122
+ if (room.processId !== void 0) {
123
+ info.processId = room.processId;
124
+ }
125
+ conflicts.push(info);
126
+ }
127
+ const filtered = this.conflictsWith ? conflicts.filter((c) => this.conflictsWith({
128
+ sessionId: c.sessionId,
129
+ joinedAt: c.joinedAt,
130
+ room: live.get(c.roomId)
131
+ }, this.room)) : conflicts;
132
+ return { conflicts: filtered, staleSessions };
133
+ }
134
+ };
135
+ export {
136
+ UniqueSessionPlugin
137
+ };
@@ -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
+ }
@@ -0,0 +1,82 @@
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/webrtc.ts
21
+ var webrtc_exports = {};
22
+ __export(webrtc_exports, {
23
+ WebRTCPlugin: () => WebRTCPlugin
24
+ });
25
+ module.exports = __toCommonJS(webrtc_exports);
26
+ var import_core = require("@colyseus/core");
27
+ var WebRTCPlugin = class extends import_core.RoomPlugin {
28
+ constructor() {
29
+ super(...arguments);
30
+ this.pluginName = "webrtc";
31
+ /**
32
+ * Session IDs that have explicitly opted into the mesh via
33
+ * `webrtc:join`. Used by `onLeave` so we only emit `webrtc:peer-left`
34
+ * for clients the rest of the mesh actually knew about.
35
+ */
36
+ this.peers = /* @__PURE__ */ new Set();
37
+ this.messages = {
38
+ /**
39
+ * Client → server. Opt this client into the mesh: reply with the
40
+ * current peer list and notify the other peers that a new one has
41
+ * joined. Idempotent — re-joining sends the (possibly extended) list
42
+ * again but doesn't double-broadcast.
43
+ */
44
+ "webrtc:join": (client) => {
45
+ if (this.peers.has(client.sessionId)) {
46
+ client.send("webrtc:peers", [...this.peers].filter((id) => id !== client.sessionId));
47
+ return;
48
+ }
49
+ const existingPeers = [...this.peers];
50
+ this.peers.add(client.sessionId);
51
+ client.send("webrtc:peers", existingPeers);
52
+ this.room.broadcast("webrtc:peer-joined", client.sessionId, { except: client });
53
+ },
54
+ /** Client → server → target client. Relays an SDP offer. */
55
+ "webrtc:offer": (client, message) => {
56
+ const target = this.room.clients.getById(message.targetId);
57
+ target?.send("webrtc:offer", { peerId: client.sessionId, sdp: message.sdp });
58
+ },
59
+ /** Client → server → target client. Relays an SDP answer. */
60
+ "webrtc:answer": (client, message) => {
61
+ const target = this.room.clients.getById(message.targetId);
62
+ target?.send("webrtc:answer", { peerId: client.sessionId, sdp: message.sdp });
63
+ },
64
+ /** Client → server → target client. Relays an ICE candidate. */
65
+ "webrtc:ice-candidate": (client, message) => {
66
+ const target = this.room.clients.getById(message.targetId);
67
+ target?.send("webrtc:ice-candidate", { peerId: client.sessionId, candidate: message.candidate });
68
+ }
69
+ };
70
+ }
71
+ onLeave(client) {
72
+ if (!this.peers.has(client.sessionId)) {
73
+ return;
74
+ }
75
+ this.peers.delete(client.sessionId);
76
+ this.room.broadcast("webrtc:peer-left", client.sessionId, { except: client });
77
+ }
78
+ };
79
+ // Annotate the CommonJS export names for ESM import in node:
80
+ 0 && (module.exports = {
81
+ WebRTCPlugin
82
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/webrtc.ts"],
4
+ "sourcesContent": ["/**\n * WebRTC signaling plugin \u2014 wires the four standard signaling messages\n * (peers list + offer/answer/ICE relay) onto a room, plus a `peer-left`\n * broadcast when a connected peer disconnects.\n *\n * Ports the standalone `@colyseus/webrtc` package as a `RoomPlugin` so\n * the plumbing is automatic \u2014 no `messages = { ...signaling }` spread\n * and no manual `onPeerDisconnected(room, client)` call from `onLeave`.\n *\n * Usage:\n *\n * import { WebRTCPlugin } from 'colyseus/plugins/webrtc';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * webrtc: new WebRTCPlugin(),\n * });\n * }\n *\n * The plugin tracks which clients have explicitly opted into the mesh\n * (sent `webrtc:join`) so `webrtc:peer-left` is only emitted for them \u2014\n * spectators and other non-WebRTC clients in the same room don't show\n * up in peer events.\n *\n * @see https://github.com/colyseus/webrtc for the original standalone\n * package this plugin is derived from.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\n\n/** WebRTC offer payload (peer \u2194 peer). */\nexport interface SignalingOffer {\n targetId: string;\n sdp: any; // RTCSessionDescriptionInit \u2014 browser type, intentionally `any` server-side\n}\n\n/** WebRTC answer payload (peer \u2194 peer). */\nexport interface SignalingAnswer {\n targetId: string;\n sdp: any;\n}\n\n/** WebRTC ICE candidate payload (peer \u2194 peer). */\nexport interface SignalingIceCandidate {\n targetId: string;\n candidate: any; // RTCIceCandidateInit \u2014 browser type\n}\n\nexport class WebRTCPlugin extends RoomPlugin {\n readonly pluginName = 'webrtc' as const;\n\n /**\n * Session IDs that have explicitly opted into the mesh via\n * `webrtc:join`. Used by `onLeave` so we only emit `webrtc:peer-left`\n * for clients the rest of the mesh actually knew about.\n */\n private peers = new Set<string>();\n\n protected messages = {\n /**\n * Client \u2192 server. Opt this client into the mesh: reply with the\n * current peer list and notify the other peers that a new one has\n * joined. Idempotent \u2014 re-joining sends the (possibly extended) list\n * again but doesn't double-broadcast.\n */\n 'webrtc:join': (client: Client) => {\n if (this.peers.has(client.sessionId)) {\n client.send('webrtc:peers', [...this.peers].filter((id) => id !== client.sessionId));\n return;\n }\n const existingPeers = [...this.peers];\n this.peers.add(client.sessionId);\n client.send('webrtc:peers', existingPeers);\n this.room.broadcast('webrtc:peer-joined', client.sessionId, { except: client });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an SDP offer. */\n 'webrtc:offer': (client: Client, message: SignalingOffer) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:offer', { peerId: client.sessionId, sdp: message.sdp });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an SDP answer. */\n 'webrtc:answer': (client: Client, message: SignalingAnswer) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:answer', { peerId: client.sessionId, sdp: message.sdp });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an ICE candidate. */\n 'webrtc:ice-candidate': (client: Client, message: SignalingIceCandidate) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:ice-candidate', { peerId: client.sessionId, candidate: message.candidate });\n },\n };\n\n protected onLeave(client: Client) {\n if (!this.peers.has(client.sessionId)) { return; }\n this.peers.delete(client.sessionId);\n this.room.broadcast('webrtc:peer-left', client.sessionId, { except: client });\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA2BA,kBAAwC;AAoBjC,IAAM,eAAN,cAA2B,uBAAW;AAAA,EAAtC;AAAA;AACL,SAAS,aAAa;AAOtB;AAAA;AAAA;AAAA;AAAA;AAAA,SAAQ,QAAQ,oBAAI,IAAY;AAEhC,SAAU,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOnB,eAAe,CAAC,WAAmB;AACjC,YAAI,KAAK,MAAM,IAAI,OAAO,SAAS,GAAG;AACpC,iBAAO,KAAK,gBAAgB,CAAC,GAAG,KAAK,KAAK,EAAE,OAAO,CAAC,OAAO,OAAO,OAAO,SAAS,CAAC;AACnF;AAAA,QACF;AACA,cAAM,gBAAgB,CAAC,GAAG,KAAK,KAAK;AACpC,aAAK,MAAM,IAAI,OAAO,SAAS;AAC/B,eAAO,KAAK,gBAAgB,aAAa;AACzC,aAAK,KAAK,UAAU,sBAAsB,OAAO,WAAW,EAAE,QAAQ,OAAO,CAAC;AAAA,MAChF;AAAA;AAAA,MAGA,gBAAgB,CAAC,QAAgB,YAA4B;AAC3D,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,gBAAgB,EAAE,QAAQ,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC;AAAA,MAC7E;AAAA;AAAA,MAGA,iBAAiB,CAAC,QAAgB,YAA6B;AAC7D,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,iBAAiB,EAAE,QAAQ,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC;AAAA,MAC9E;AAAA;AAAA,MAGA,wBAAwB,CAAC,QAAgB,YAAmC;AAC1E,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,wBAAwB,EAAE,QAAQ,OAAO,WAAW,WAAW,QAAQ,UAAU,CAAC;AAAA,MACjG;AAAA,IACF;AAAA;AAAA,EAEU,QAAQ,QAAgB;AAChC,QAAI,CAAC,KAAK,MAAM,IAAI,OAAO,SAAS,GAAG;AAAE;AAAA,IAAQ;AACjD,SAAK,MAAM,OAAO,OAAO,SAAS;AAClC,SAAK,KAAK,UAAU,oBAAoB,OAAO,WAAW,EAAE,QAAQ,OAAO,CAAC;AAAA,EAC9E;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * WebRTC signaling plugin — wires the four standard signaling messages
3
+ * (peers list + offer/answer/ICE relay) onto a room, plus a `peer-left`
4
+ * broadcast when a connected peer disconnects.
5
+ *
6
+ * Ports the standalone `@colyseus/webrtc` package as a `RoomPlugin` so
7
+ * the plumbing is automatic — no `messages = { ...signaling }` spread
8
+ * and no manual `onPeerDisconnected(room, client)` call from `onLeave`.
9
+ *
10
+ * Usage:
11
+ *
12
+ * import { WebRTCPlugin } from 'colyseus/plugins/webrtc';
13
+ *
14
+ * class GameRoom extends Room {
15
+ * plugins = definePlugins({
16
+ * webrtc: new WebRTCPlugin(),
17
+ * });
18
+ * }
19
+ *
20
+ * The plugin tracks which clients have explicitly opted into the mesh
21
+ * (sent `webrtc:join`) so `webrtc:peer-left` is only emitted for them —
22
+ * spectators and other non-WebRTC clients in the same room don't show
23
+ * up in peer events.
24
+ *
25
+ * @see https://github.com/colyseus/webrtc for the original standalone
26
+ * package this plugin is derived from.
27
+ */
28
+ import { RoomPlugin, type Client } from '@colyseus/core';
29
+ /** WebRTC offer payload (peer ↔ peer). */
30
+ export interface SignalingOffer {
31
+ targetId: string;
32
+ sdp: any;
33
+ }
34
+ /** WebRTC answer payload (peer ↔ peer). */
35
+ export interface SignalingAnswer {
36
+ targetId: string;
37
+ sdp: any;
38
+ }
39
+ /** WebRTC ICE candidate payload (peer ↔ peer). */
40
+ export interface SignalingIceCandidate {
41
+ targetId: string;
42
+ candidate: any;
43
+ }
44
+ export declare class WebRTCPlugin extends RoomPlugin {
45
+ readonly pluginName: "webrtc";
46
+ /**
47
+ * Session IDs that have explicitly opted into the mesh via
48
+ * `webrtc:join`. Used by `onLeave` so we only emit `webrtc:peer-left`
49
+ * for clients the rest of the mesh actually knew about.
50
+ */
51
+ private peers;
52
+ protected messages: {
53
+ /**
54
+ * Client → server. Opt this client into the mesh: reply with the
55
+ * current peer list and notify the other peers that a new one has
56
+ * joined. Idempotent — re-joining sends the (possibly extended) list
57
+ * again but doesn't double-broadcast.
58
+ */
59
+ 'webrtc:join': (client: Client) => void;
60
+ /** Client → server → target client. Relays an SDP offer. */
61
+ 'webrtc:offer': (client: Client, message: SignalingOffer) => void;
62
+ /** Client → server → target client. Relays an SDP answer. */
63
+ 'webrtc:answer': (client: Client, message: SignalingAnswer) => void;
64
+ /** Client → server → target client. Relays an ICE candidate. */
65
+ 'webrtc:ice-candidate': (client: Client, message: SignalingIceCandidate) => void;
66
+ };
67
+ protected onLeave(client: Client): void;
68
+ }
@@ -0,0 +1,57 @@
1
+ // bundles/colyseus/src/plugins/webrtc.ts
2
+ import { RoomPlugin } from "@colyseus/core";
3
+ var WebRTCPlugin = class extends RoomPlugin {
4
+ constructor() {
5
+ super(...arguments);
6
+ this.pluginName = "webrtc";
7
+ /**
8
+ * Session IDs that have explicitly opted into the mesh via
9
+ * `webrtc:join`. Used by `onLeave` so we only emit `webrtc:peer-left`
10
+ * for clients the rest of the mesh actually knew about.
11
+ */
12
+ this.peers = /* @__PURE__ */ new Set();
13
+ this.messages = {
14
+ /**
15
+ * Client → server. Opt this client into the mesh: reply with the
16
+ * current peer list and notify the other peers that a new one has
17
+ * joined. Idempotent — re-joining sends the (possibly extended) list
18
+ * again but doesn't double-broadcast.
19
+ */
20
+ "webrtc:join": (client) => {
21
+ if (this.peers.has(client.sessionId)) {
22
+ client.send("webrtc:peers", [...this.peers].filter((id) => id !== client.sessionId));
23
+ return;
24
+ }
25
+ const existingPeers = [...this.peers];
26
+ this.peers.add(client.sessionId);
27
+ client.send("webrtc:peers", existingPeers);
28
+ this.room.broadcast("webrtc:peer-joined", client.sessionId, { except: client });
29
+ },
30
+ /** Client → server → target client. Relays an SDP offer. */
31
+ "webrtc:offer": (client, message) => {
32
+ const target = this.room.clients.getById(message.targetId);
33
+ target?.send("webrtc:offer", { peerId: client.sessionId, sdp: message.sdp });
34
+ },
35
+ /** Client → server → target client. Relays an SDP answer. */
36
+ "webrtc:answer": (client, message) => {
37
+ const target = this.room.clients.getById(message.targetId);
38
+ target?.send("webrtc:answer", { peerId: client.sessionId, sdp: message.sdp });
39
+ },
40
+ /** Client → server → target client. Relays an ICE candidate. */
41
+ "webrtc:ice-candidate": (client, message) => {
42
+ const target = this.room.clients.getById(message.targetId);
43
+ target?.send("webrtc:ice-candidate", { peerId: client.sessionId, candidate: message.candidate });
44
+ }
45
+ };
46
+ }
47
+ onLeave(client) {
48
+ if (!this.peers.has(client.sessionId)) {
49
+ return;
50
+ }
51
+ this.peers.delete(client.sessionId);
52
+ this.room.broadcast("webrtc:peer-left", client.sessionId, { except: client });
53
+ }
54
+ };
55
+ export {
56
+ WebRTCPlugin
57
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/plugins/webrtc.ts"],
4
+ "sourcesContent": ["/**\n * WebRTC signaling plugin \u2014 wires the four standard signaling messages\n * (peers list + offer/answer/ICE relay) onto a room, plus a `peer-left`\n * broadcast when a connected peer disconnects.\n *\n * Ports the standalone `@colyseus/webrtc` package as a `RoomPlugin` so\n * the plumbing is automatic \u2014 no `messages = { ...signaling }` spread\n * and no manual `onPeerDisconnected(room, client)` call from `onLeave`.\n *\n * Usage:\n *\n * import { WebRTCPlugin } from 'colyseus/plugins/webrtc';\n *\n * class GameRoom extends Room {\n * plugins = definePlugins({\n * webrtc: new WebRTCPlugin(),\n * });\n * }\n *\n * The plugin tracks which clients have explicitly opted into the mesh\n * (sent `webrtc:join`) so `webrtc:peer-left` is only emitted for them \u2014\n * spectators and other non-WebRTC clients in the same room don't show\n * up in peer events.\n *\n * @see https://github.com/colyseus/webrtc for the original standalone\n * package this plugin is derived from.\n */\nimport { RoomPlugin, type Client } from '@colyseus/core';\n\n/** WebRTC offer payload (peer \u2194 peer). */\nexport interface SignalingOffer {\n targetId: string;\n sdp: any; // RTCSessionDescriptionInit \u2014 browser type, intentionally `any` server-side\n}\n\n/** WebRTC answer payload (peer \u2194 peer). */\nexport interface SignalingAnswer {\n targetId: string;\n sdp: any;\n}\n\n/** WebRTC ICE candidate payload (peer \u2194 peer). */\nexport interface SignalingIceCandidate {\n targetId: string;\n candidate: any; // RTCIceCandidateInit \u2014 browser type\n}\n\nexport class WebRTCPlugin extends RoomPlugin {\n readonly pluginName = 'webrtc' as const;\n\n /**\n * Session IDs that have explicitly opted into the mesh via\n * `webrtc:join`. Used by `onLeave` so we only emit `webrtc:peer-left`\n * for clients the rest of the mesh actually knew about.\n */\n private peers = new Set<string>();\n\n protected messages = {\n /**\n * Client \u2192 server. Opt this client into the mesh: reply with the\n * current peer list and notify the other peers that a new one has\n * joined. Idempotent \u2014 re-joining sends the (possibly extended) list\n * again but doesn't double-broadcast.\n */\n 'webrtc:join': (client: Client) => {\n if (this.peers.has(client.sessionId)) {\n client.send('webrtc:peers', [...this.peers].filter((id) => id !== client.sessionId));\n return;\n }\n const existingPeers = [...this.peers];\n this.peers.add(client.sessionId);\n client.send('webrtc:peers', existingPeers);\n this.room.broadcast('webrtc:peer-joined', client.sessionId, { except: client });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an SDP offer. */\n 'webrtc:offer': (client: Client, message: SignalingOffer) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:offer', { peerId: client.sessionId, sdp: message.sdp });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an SDP answer. */\n 'webrtc:answer': (client: Client, message: SignalingAnswer) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:answer', { peerId: client.sessionId, sdp: message.sdp });\n },\n\n /** Client \u2192 server \u2192 target client. Relays an ICE candidate. */\n 'webrtc:ice-candidate': (client: Client, message: SignalingIceCandidate) => {\n const target = this.room.clients.getById(message.targetId);\n target?.send('webrtc:ice-candidate', { peerId: client.sessionId, candidate: message.candidate });\n },\n };\n\n protected onLeave(client: Client) {\n if (!this.peers.has(client.sessionId)) { return; }\n this.peers.delete(client.sessionId);\n this.room.broadcast('webrtc:peer-left', client.sessionId, { except: client });\n }\n}\n"],
5
+ "mappings": ";AA2BA,SAAS,kBAA+B;AAoBjC,IAAM,eAAN,cAA2B,WAAW;AAAA,EAAtC;AAAA;AACL,SAAS,aAAa;AAOtB;AAAA;AAAA;AAAA;AAAA;AAAA,SAAQ,QAAQ,oBAAI,IAAY;AAEhC,SAAU,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOnB,eAAe,CAAC,WAAmB;AACjC,YAAI,KAAK,MAAM,IAAI,OAAO,SAAS,GAAG;AACpC,iBAAO,KAAK,gBAAgB,CAAC,GAAG,KAAK,KAAK,EAAE,OAAO,CAAC,OAAO,OAAO,OAAO,SAAS,CAAC;AACnF;AAAA,QACF;AACA,cAAM,gBAAgB,CAAC,GAAG,KAAK,KAAK;AACpC,aAAK,MAAM,IAAI,OAAO,SAAS;AAC/B,eAAO,KAAK,gBAAgB,aAAa;AACzC,aAAK,KAAK,UAAU,sBAAsB,OAAO,WAAW,EAAE,QAAQ,OAAO,CAAC;AAAA,MAChF;AAAA;AAAA,MAGA,gBAAgB,CAAC,QAAgB,YAA4B;AAC3D,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,gBAAgB,EAAE,QAAQ,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC;AAAA,MAC7E;AAAA;AAAA,MAGA,iBAAiB,CAAC,QAAgB,YAA6B;AAC7D,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,iBAAiB,EAAE,QAAQ,OAAO,WAAW,KAAK,QAAQ,IAAI,CAAC;AAAA,MAC9E;AAAA;AAAA,MAGA,wBAAwB,CAAC,QAAgB,YAAmC;AAC1E,cAAM,SAAS,KAAK,KAAK,QAAQ,QAAQ,QAAQ,QAAQ;AACzD,gBAAQ,KAAK,wBAAwB,EAAE,QAAQ,OAAO,WAAW,WAAW,QAAQ,UAAU,CAAC;AAAA,MACjG;AAAA,IACF;AAAA;AAAA,EAEU,QAAQ,QAAgB;AAChC,QAAI,CAAC,KAAK,MAAM,IAAI,OAAO,SAAS,GAAG;AAAE;AAAA,IAAQ;AACjD,SAAK,MAAM,OAAO,OAAO,SAAS;AAClC,SAAK,KAAK,UAAU,oBAAoB,OAAO,WAAW,EAAE,QAAQ,OAAO,CAAC;AAAA,EAC9E;AACF;",
6
+ "names": []
7
+ }
package/build/vite.cjs CHANGED
@@ -167,11 +167,14 @@ function colyseus(options) {
167
167
  currentAppHandler(req, res, next);
168
168
  });
169
169
  return async () => {
170
- if (!server.httpServer) {
171
- throw new Error("[colyseus] Vite HTTP server not available.");
170
+ const httpServer = options.httpServer ?? server.httpServer;
171
+ if (!httpServer) {
172
+ throw new Error(
173
+ "[colyseus] No HTTP server available. When running Vite in middlewareMode, pass `httpServer` to the colyseus() plugin."
174
+ );
172
175
  }
173
- await loadServerModule();
174
- console.log("[colyseus] Server ready on Vite's HTTP server");
176
+ await loadServerModule(httpServer);
177
+ console.log("[colyseus] Server ready on " + (options.httpServer ? "user-provided" : "Vite's") + " HTTP server");
175
178
  };
176
179
  }
177
180
  },
@@ -190,7 +193,7 @@ function colyseus(options) {
190
193
  }
191
194
  }
192
195
  ];
193
- async function loadServerModule() {
196
+ async function loadServerModule(httpServer) {
194
197
  const env = viteServer.environments.colyseus;
195
198
  if (!env) {
196
199
  console.error("[colyseus] Environment not found");
@@ -208,7 +211,7 @@ function colyseus(options) {
208
211
  if (typeof transport.attachToServer !== "function") {
209
212
  throw new Error("[colyseus] Vite dev mode requires a transport with attachToServer().");
210
213
  }
211
- transport.attachToServer(viteServer.httpServer, {
214
+ transport.attachToServer(httpServer ?? viteServer.httpServer, {
212
215
  filter(req) {
213
216
  return /^\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\/?$/.test(
214
217
  new URL(req.url || "", "http://localhost").pathname
@@ -223,9 +226,10 @@ function colyseus(options) {
223
226
  const router = config?.router;
224
227
  if (!expressApp && config?.options?.express) {
225
228
  try {
226
- const express = (await (0, import_core.dynamicImport)("express")).default;
229
+ const expressModule = await (0, import_core.dynamicImport)("express");
230
+ const express = expressModule?.default ?? expressModule;
227
231
  expressApp = express();
228
- config.options.express(expressApp);
232
+ await config.options.express(expressApp);
229
233
  } catch (e) {
230
234
  console.warn("[colyseus] Express not available. Install express to use the express option.");
231
235
  }