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.
- package/build/plugins/idle-kick.cjs +67 -0
- package/build/plugins/idle-kick.cjs.map +7 -0
- package/build/plugins/idle-kick.d.ts +65 -0
- package/build/plugins/idle-kick.mjs +42 -0
- package/build/plugins/idle-kick.mjs.map +7 -0
- package/build/plugins/track-user-sessions.cjs +75 -0
- package/build/plugins/track-user-sessions.cjs.map +7 -0
- package/build/plugins/track-user-sessions.d.ts +65 -0
- package/build/plugins/track-user-sessions.mjs +55 -0
- package/build/plugins/track-user-sessions.mjs.map +7 -0
- package/build/plugins/unique-session.cjs +158 -0
- package/build/plugins/unique-session.cjs.map +7 -0
- package/build/plugins/unique-session.d.ts +174 -0
- package/build/plugins/unique-session.mjs +137 -0
- package/build/plugins/unique-session.mjs.map +7 -0
- package/build/plugins/webrtc.cjs +82 -0
- package/build/plugins/webrtc.cjs.map +7 -0
- package/build/plugins/webrtc.d.ts +68 -0
- package/build/plugins/webrtc.mjs +57 -0
- package/build/plugins/webrtc.mjs.map +7 -0
- package/build/vite.cjs +12 -8
- package/build/vite.cjs.map +2 -2
- package/build/vite.d.ts +9 -0
- package/build/vite.mjs +12 -8
- package/build/vite.mjs.map +2 -2
- package/package.json +48 -24
- package/src/plugins/idle-kick.ts +113 -0
- package/src/plugins/track-user-sessions.ts +80 -0
- package/src/plugins/unique-session.ts +334 -0
- package/src/plugins/webrtc.ts +100 -0
- package/src/vite.ts +22 -8
|
@@ -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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
}
|