onelobby 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/chat.d.ts +60 -0
  4. package/dist/chat.js +163 -0
  5. package/dist/chat.js.map +1 -0
  6. package/dist/chunking.d.ts +17 -0
  7. package/dist/chunking.js +59 -0
  8. package/dist/chunking.js.map +1 -0
  9. package/dist/clock.d.ts +8 -0
  10. package/dist/clock.js +39 -0
  11. package/dist/clock.js.map +1 -0
  12. package/dist/codec.d.ts +141 -0
  13. package/dist/codec.js +308 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/crockford.d.ts +14 -0
  16. package/dist/crockford.js +55 -0
  17. package/dist/crockford.js.map +1 -0
  18. package/dist/deflate.d.ts +4 -0
  19. package/dist/deflate.js +32 -0
  20. package/dist/deflate.js.map +1 -0
  21. package/dist/deprecated.d.ts +18 -0
  22. package/dist/deprecated.js +11 -0
  23. package/dist/deprecated.js.map +1 -0
  24. package/dist/direct/hub-host.d.ts +82 -0
  25. package/dist/direct/hub-host.js +393 -0
  26. package/dist/direct/hub-host.js.map +1 -0
  27. package/dist/direct/hub.d.ts +178 -0
  28. package/dist/direct/hub.js +862 -0
  29. package/dist/direct/hub.js.map +1 -0
  30. package/dist/direct/index.d.ts +57 -0
  31. package/dist/direct/index.js +71 -0
  32. package/dist/direct/index.js.map +1 -0
  33. package/dist/direct/portmap.d.ts +84 -0
  34. package/dist/direct/portmap.js +465 -0
  35. package/dist/direct/portmap.js.map +1 -0
  36. package/dist/direct-address.d.ts +20 -0
  37. package/dist/direct-address.js +61 -0
  38. package/dist/direct-address.js.map +1 -0
  39. package/dist/emitter.d.ts +9 -0
  40. package/dist/emitter.js +36 -0
  41. package/dist/emitter.js.map +1 -0
  42. package/dist/friendly.d.ts +20 -0
  43. package/dist/friendly.js +120 -0
  44. package/dist/friendly.js.map +1 -0
  45. package/dist/global.d.ts +8 -0
  46. package/dist/global.js +9 -0
  47. package/dist/global.js.map +1 -0
  48. package/dist/gridpaint.d.ts +27 -0
  49. package/dist/gridpaint.js +106 -0
  50. package/dist/gridpaint.js.map +1 -0
  51. package/dist/hub.d.ts +25 -0
  52. package/dist/hub.js +2 -0
  53. package/dist/hub.js.map +1 -0
  54. package/dist/index.d.ts +35 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/internals.d.ts +28 -0
  58. package/dist/internals.js +24 -0
  59. package/dist/internals.js.map +1 -0
  60. package/dist/joincode.d.ts +21 -0
  61. package/dist/joincode.js +84 -0
  62. package/dist/joincode.js.map +1 -0
  63. package/dist/lobby.d.ts +203 -0
  64. package/dist/lobby.js +840 -0
  65. package/dist/lobby.js.map +1 -0
  66. package/dist/lockstep.d.ts +132 -0
  67. package/dist/lockstep.js +471 -0
  68. package/dist/lockstep.js.map +1 -0
  69. package/dist/migration.d.ts +17 -0
  70. package/dist/migration.js +34 -0
  71. package/dist/migration.js.map +1 -0
  72. package/dist/onelobby.bundle.mjs +140 -0
  73. package/dist/onelobby.bundle.mjs.map +7 -0
  74. package/dist/onelobby.d.ts +151 -0
  75. package/dist/onelobby.global.js +3897 -0
  76. package/dist/onelobby.global.js.map +7 -0
  77. package/dist/onelobby.global.min.js +140 -0
  78. package/dist/onelobby.global.min.js.map +7 -0
  79. package/dist/onelobby.js +337 -0
  80. package/dist/onelobby.js.map +1 -0
  81. package/dist/signaling.d.ts +94 -0
  82. package/dist/signaling.js +134 -0
  83. package/dist/signaling.js.map +1 -0
  84. package/dist/socket.d.ts +63 -0
  85. package/dist/socket.js +142 -0
  86. package/dist/socket.js.map +1 -0
  87. package/dist/testing.d.ts +135 -0
  88. package/dist/testing.js +438 -0
  89. package/dist/testing.js.map +1 -0
  90. package/dist/transport.d.ts +80 -0
  91. package/dist/transport.js +7 -0
  92. package/dist/transport.js.map +1 -0
  93. package/dist/ui/controller.d.ts +137 -0
  94. package/dist/ui/controller.js +337 -0
  95. package/dist/ui/controller.js.map +1 -0
  96. package/dist/ui/element.d.ts +22 -0
  97. package/dist/ui/element.js +284 -0
  98. package/dist/ui/element.js.map +1 -0
  99. package/dist/ui/index.d.ts +5 -0
  100. package/dist/ui/index.js +9 -0
  101. package/dist/ui/index.js.map +1 -0
  102. package/dist/ui/server-url.d.ts +9 -0
  103. package/dist/ui/server-url.js +39 -0
  104. package/dist/ui/server-url.js.map +1 -0
  105. package/dist/ui/styles.d.ts +2 -0
  106. package/dist/ui/styles.js +70 -0
  107. package/dist/ui/styles.js.map +1 -0
  108. package/dist/voice-browser.d.ts +19 -0
  109. package/dist/voice-browser.js +151 -0
  110. package/dist/voice-browser.js.map +1 -0
  111. package/dist/voice-core.d.ts +98 -0
  112. package/dist/voice-core.js +233 -0
  113. package/dist/voice-core.js.map +1 -0
  114. package/dist/voice-types.d.ts +35 -0
  115. package/dist/voice-types.js +3 -0
  116. package/dist/voice-types.js.map +1 -0
  117. package/dist/voice.d.ts +68 -0
  118. package/dist/voice.js +223 -0
  119. package/dist/voice.js.map +1 -0
  120. package/dist/webrtc.d.ts +29 -0
  121. package/dist/webrtc.js +169 -0
  122. package/dist/webrtc.js.map +1 -0
  123. package/package.json +93 -4
@@ -0,0 +1,862 @@
1
+ /// <reference types="node" />
2
+ import { createHash, createHmac, randomBytes, randomInt } from "node:crypto";
3
+ /**
4
+ * The signaling hub (protocol lk1) in TypeScript, for a Node process that hosts direct-IP lobbies
5
+ * (docs/direct-ip.md s.2): a line-by-line port of `server/OneLobby.Hub/Hub.cs`, held to the same rules by
6
+ * `conformance/server`. Transport-agnostic: feed it text frames with `receive`, call `tick` about once a second.
7
+ * Metrics are left out (that is onelobby-server's job); everything else (codes, list, resume, private lobbies,
8
+ * kicks, migration claims, TURN credentials, rate limits) behaves as in C#.
9
+ */
10
+ export const PROTO = "lk1";
11
+ const NAME_MAX = 24;
12
+ const LOBBY_NAME_MAX = 48;
13
+ const META_KEYS_MAX = 16;
14
+ const META_LEN_MAX = 64;
15
+ const PENDING_MAX = 8;
16
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
17
+ const GAME_ID = /^[A-Za-z0-9._-]{1,64}$/;
18
+ export function defaultHubOptions() {
19
+ return {
20
+ turnUrls: [], turnSecret: "", stunUrls: ["stun:stun.l.google.com:19302"], region: "", allowedGames: [], gameQuotas: {},
21
+ maxLobbies: 1000, maxPeers: 10000, hardMaxPlayers: 64, rateScale: 1, turnTtlSeconds: 3600, maxMessageBytes: 16 * 1024,
22
+ disableIpLimits: false, createPerMin: 10, joinPerMin: 60, signalPerSec: 20, openLobbiesPerIp: 8, idleTimeoutMs: 30_000,
23
+ resumeWindowMs: 60_000, codeReuseMs: 24 * 3600 * 1000, listLimitMax: 100, singleLobby: false, password: null, issueCodes: true,
24
+ };
25
+ }
26
+ /** Settings for a hub inside the host's game (docs/direct-ip.md s.2): no codes, no list, no ICE servers. */
27
+ export function directHubOptions(maxPlayers, password) {
28
+ const n = Math.min(250, Math.max(2, maxPlayers));
29
+ return {
30
+ ...defaultHubOptions(),
31
+ singleLobby: true, issueCodes: false, password: password ? password : null, stunUrls: [], turnUrls: [],
32
+ maxLobbies: 1, hardMaxPlayers: n, maxPeers: n + 8,
33
+ };
34
+ }
35
+ /** Sliding-window counter: at most `limit` events per `windowMs`. */
36
+ export class RateWindow {
37
+ limit;
38
+ windowMs;
39
+ hits = [];
40
+ constructor(limit, windowMs) {
41
+ this.limit = limit;
42
+ this.windowMs = windowMs;
43
+ }
44
+ tryHit(now) {
45
+ this.trim(now);
46
+ if (this.hits.length >= this.limit)
47
+ return false;
48
+ this.hits.push(now);
49
+ return true;
50
+ }
51
+ retryAfterMs(now) {
52
+ this.trim(now);
53
+ return this.hits.length < this.limit || this.hits.length === 0 ? 0 : Math.max(1, this.windowMs - (now - this.hits[0]));
54
+ }
55
+ isIdle(now) {
56
+ this.trim(now);
57
+ return this.hits.length === 0;
58
+ }
59
+ trim(now) {
60
+ while (this.hits.length > 0 && now - this.hits[0] >= this.windowMs)
61
+ this.hits.shift();
62
+ }
63
+ }
64
+ /** Control characters and bidi overrides out, then at most `max` UTF-16 units without splitting a surrogate pair. */
65
+ export function sanitize(s, max, fallback = "") {
66
+ if (!s)
67
+ return fallback;
68
+ let r = "";
69
+ for (const ch of s) {
70
+ const c = ch.charCodeAt(0);
71
+ if (ch.length === 1 && (c <= 0x1f || (c >= 0x7f && c <= 0x9f) || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)))
72
+ continue;
73
+ r += ch;
74
+ }
75
+ if (r.length > max) {
76
+ r = r.slice(0, max);
77
+ const last = r.charCodeAt(r.length - 1);
78
+ if (last >= 0xd800 && last <= 0xdbff)
79
+ r = r.slice(0, -1);
80
+ }
81
+ return r.length === 0 ? fallback : r;
82
+ }
83
+ function normalizeCode(input) {
84
+ if (typeof input !== "string")
85
+ return null;
86
+ let out = "";
87
+ for (const raw of input) {
88
+ if (raw === " " || raw === "-")
89
+ continue;
90
+ let ch = raw >= "a" && raw <= "z" ? raw.toUpperCase() : raw;
91
+ if (ch === "I" || ch === "L")
92
+ ch = "1";
93
+ else if (ch === "O")
94
+ ch = "0";
95
+ if (CROCKFORD.indexOf(ch) < 0 || out.length >= 6)
96
+ return null;
97
+ out += ch;
98
+ }
99
+ return out.length === 6 ? out : null;
100
+ }
101
+ /** One signaling connection. The hub only calls `send` and `close`. */
102
+ export class Session {
103
+ id;
104
+ ip;
105
+ connId;
106
+ send;
107
+ close;
108
+ signalRate;
109
+ closed = false;
110
+ lastSeen = 0;
111
+ helloDone = false;
112
+ gameId = "";
113
+ netVersion = "";
114
+ name = "";
115
+ lobby = null;
116
+ member = null;
117
+ pendingIn = null;
118
+ pendingMember = null;
119
+ constructor(id, ip, connId, send, close, signalRate) {
120
+ this.id = id;
121
+ this.ip = ip;
122
+ this.connId = connId;
123
+ this.send = send;
124
+ this.close = close;
125
+ this.signalRate = signalRate;
126
+ }
127
+ }
128
+ class Member {
129
+ peerId = 0;
130
+ name = "";
131
+ joinedAt = 0;
132
+ session = null;
133
+ detachedAt = 0;
134
+ token = "";
135
+ ip = "";
136
+ }
137
+ class HubLobby {
138
+ code = "";
139
+ gameId = "";
140
+ netVersion = "";
141
+ name = "";
142
+ visibility = "public";
143
+ mode = "lockstep";
144
+ tickRate = 20;
145
+ lateJoin = false;
146
+ relayOnly = false;
147
+ phase = "lobby";
148
+ maxPlayers = 0;
149
+ createdAt = 0;
150
+ ownerIp = "";
151
+ meta = {};
152
+ members = [];
153
+ pending = [];
154
+ kicked = new Set();
155
+ hostId = 0;
156
+ epoch = 0;
157
+ nextPeerId = 1;
158
+ find(peerId) {
159
+ return this.members.find((m) => m.peerId === peerId);
160
+ }
161
+ }
162
+ const KNOWN_TYPES = new Set(["hello", "create", "join", "list", "leave", "signal", "meta", "phase", "kick", "approve", "ping", "host_changed", "path"]);
163
+ const str = (o, k) => (typeof o[k] === "string" ? o[k] : null);
164
+ const int = (o, k) => {
165
+ const v = o[k];
166
+ return typeof v === "number" && Number.isInteger(v) && Math.abs(v) < 2147483647 ? v : null;
167
+ };
168
+ const bool = (o, k) => (typeof o[k] === "boolean" ? o[k] : null);
169
+ const has = (o, k) => o[k] !== undefined && o[k] !== null;
170
+ const field = (name) => ({ field: name });
171
+ const hex = (n) => randomBytes(n).toString("hex");
172
+ const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
173
+ /** Not sanitised (spec/signaling.md): over the limits or non-string values are rejected. */
174
+ function readMeta(node) {
175
+ if (node === undefined || node === null)
176
+ return { meta: {} };
177
+ if (typeof node !== "object" || Array.isArray(node))
178
+ return { err: "meta must be an object of strings" };
179
+ const entries = Object.entries(node);
180
+ if (entries.length > META_KEYS_MAX)
181
+ return { err: `meta allows at most ${META_KEYS_MAX} keys` };
182
+ const meta = {};
183
+ for (const [k, v] of entries.sort(([a], [b]) => byCode(a, b))) {
184
+ if (typeof v !== "string")
185
+ return { err: "meta values must be strings" };
186
+ if (k.length > META_LEN_MAX || v.length > META_LEN_MAX)
187
+ return { err: `meta keys and values are limited to ${META_LEN_MAX} chars` };
188
+ meta[k] = v;
189
+ }
190
+ return { meta };
191
+ }
192
+ /** All signaling state and rules (DESIGN s.6, s.7, s.9, s.10, s.12; spec/signaling.md). */
193
+ export class SignalingHub {
194
+ o;
195
+ now;
196
+ rand;
197
+ log;
198
+ lobbies = new Map();
199
+ retired = new Map();
200
+ tokens = new Map();
201
+ sessions = new Map();
202
+ gameLobbies = new Map();
203
+ ips = new Map();
204
+ nextSession = 0;
205
+ createdOnce = false;
206
+ members = 0;
207
+ logSalt = hex(8);
208
+ constructor(options = {}, deps = {}) {
209
+ this.o = { ...defaultHubOptions(), ...options };
210
+ this.now = deps.now ?? (() => Date.now());
211
+ this.rand = deps.randomInt ?? ((n) => randomInt(n));
212
+ this.log = deps.log ?? (() => { });
213
+ }
214
+ get lobbyCount() {
215
+ return this.lobbies.size;
216
+ }
217
+ get socketCount() {
218
+ return this.sessions.size;
219
+ }
220
+ get scale() {
221
+ return Math.max(1, this.o.rateScale);
222
+ }
223
+ // ---------------------------------------------------------------- connection lifecycle
224
+ /** Registers a socket. Returns null (after sending server_full and closing) when over maxPeers. */
225
+ connect(ip, send, close) {
226
+ const s = new Session(++this.nextSession, ip, hex(6), send, close, new RateWindow(this.o.signalPerSec * this.scale, 1000));
227
+ s.lastSeen = this.now();
228
+ if (this.sessions.size >= this.o.maxPeers) {
229
+ this.error(s, "server_full", "server is at its peer limit");
230
+ s.close();
231
+ return null;
232
+ }
233
+ this.sessions.set(s.id, s);
234
+ this.ipOf(ip).sockets++;
235
+ return s;
236
+ }
237
+ disconnect(s) {
238
+ if (!this.sessions.delete(s.id))
239
+ return;
240
+ s.closed = true;
241
+ const st = this.ips.get(s.ip);
242
+ if (st)
243
+ st.sockets--;
244
+ if (s.pendingIn) {
245
+ s.pendingIn.pending = s.pendingIn.pending.filter((p) => p !== s.pendingMember);
246
+ s.pendingIn = null;
247
+ s.pendingMember = null;
248
+ }
249
+ const m = s.member;
250
+ const lobby = s.lobby;
251
+ if (m && lobby) {
252
+ // The slot and token stay reserved for the resume window (tick expires them); others hear peer_left{timeout} now.
253
+ m.session = null;
254
+ m.detachedAt = this.now();
255
+ s.member = null;
256
+ s.lobby = null;
257
+ this.logEvent("detach", lobby, s);
258
+ this.broadcast(lobby, { t: "peer_left", peerId: m.peerId, reason: "timeout" });
259
+ if (lobby.hostId === m.peerId)
260
+ this.promoteHost(lobby, m.peerId);
261
+ }
262
+ }
263
+ closeSession(s) {
264
+ s.close();
265
+ this.disconnect(s);
266
+ }
267
+ // ---------------------------------------------------------------- time
268
+ /** Call about once a second: idle timeouts, resume-window expiry, code retirement. */
269
+ tick() {
270
+ const now = this.now();
271
+ for (const s of [...this.sessions.values()])
272
+ if (now - s.lastSeen > this.o.idleTimeoutMs)
273
+ this.closeSession(s);
274
+ for (const lobby of [...this.lobbies.values()])
275
+ for (const m of [...lobby.members])
276
+ if (m.detachedAt > 0 && now - m.detachedAt >= this.o.resumeWindowMs)
277
+ this.removeMember(lobby, m, "timeout");
278
+ for (const [code, at] of [...this.retired])
279
+ if (now - at >= this.o.codeReuseMs)
280
+ this.retired.delete(code);
281
+ for (const [key, st] of [...this.ips])
282
+ if (st.sockets <= 0 && st.open <= 0 && st.create.isIdle(now) && st.join.isIdle(now))
283
+ this.ips.delete(key);
284
+ }
285
+ // ---------------------------------------------------------------- inbound
286
+ receive(s, text) {
287
+ if (s.closed)
288
+ return;
289
+ s.lastSeen = this.now();
290
+ if (text.length > this.o.maxMessageBytes) {
291
+ this.error(s, "bad_request", "message too large");
292
+ this.closeSession(s);
293
+ return;
294
+ }
295
+ let msg = null;
296
+ try {
297
+ const v = JSON.parse(text);
298
+ msg = v && typeof v === "object" && !Array.isArray(v) ? v : null;
299
+ }
300
+ catch {
301
+ msg = null;
302
+ }
303
+ const t = msg ? str(msg, "t") : null;
304
+ if (!msg || t === null) {
305
+ this.error(s, "bad_request", "expected a JSON object with a string t", field("t"));
306
+ return;
307
+ }
308
+ if (t === "hello")
309
+ return this.hello(s, msg);
310
+ if (!s.helloDone) {
311
+ if (KNOWN_TYPES.has(t))
312
+ this.error(s, "bad_request", "send hello first");
313
+ return;
314
+ }
315
+ switch (t) {
316
+ case "create": return this.create(s, msg);
317
+ case "join": return this.join(s, msg);
318
+ case "list": return this.list(s, msg);
319
+ case "leave": return this.leave(s);
320
+ case "signal": return this.signal(s, msg);
321
+ case "meta": return this.hostMeta(s, msg);
322
+ case "phase": return this.hostPhase(s, msg);
323
+ case "kick": return this.kick(s, msg);
324
+ case "approve": return this.approve(s, msg);
325
+ case "ping": {
326
+ const n = int(msg, "n");
327
+ if (n === null || n < 0)
328
+ return this.error(s, "bad_request", "ping needs an integer n", field("n"));
329
+ return this.send(s, { t: "pong", n });
330
+ }
331
+ case "host_changed": return this.claimHost(s, msg);
332
+ case "path": return this.pathReport(s, msg);
333
+ default: return; // unknown types are ignored
334
+ }
335
+ }
336
+ // ---------------------------------------------------------------- hello / welcome
337
+ hello(s, m) {
338
+ if (s.helloDone)
339
+ return this.error(s, "bad_request", "hello already sent");
340
+ if (str(m, "proto") !== PROTO) {
341
+ this.error(s, "bad_request", "unsupported proto, expected lk1", { supported: [PROTO] });
342
+ this.closeSession(s);
343
+ return;
344
+ }
345
+ if (has(m, "name") && str(m, "name") === null)
346
+ return this.error(s, "bad_request", "name must be a string", field("name"));
347
+ if (has(m, "client") && (typeof m.client !== "object" || Array.isArray(m.client)))
348
+ return this.error(s, "bad_request", "client must be an object", field("client"));
349
+ const gameId = str(m, "gameId");
350
+ const netVersion = str(m, "netVersion");
351
+ if (gameId === null || !GAME_ID.test(gameId))
352
+ return this.error(s, "bad_request", "gameId must match [A-Za-z0-9._-]{1,64}", field("gameId"));
353
+ if (!netVersion || netVersion.length > 32)
354
+ return this.error(s, "bad_request", "netVersion must be 1-32 characters", field("netVersion"));
355
+ if (this.o.allowedGames.length > 0 && !this.o.allowedGames.includes(gameId)) {
356
+ this.error(s, "game_not_allowed", "this server does not host that game");
357
+ this.closeSession(s);
358
+ return;
359
+ }
360
+ s.gameId = gameId;
361
+ s.netVersion = netVersion;
362
+ s.name = sanitize(str(m, "name"), NAME_MAX, "Player");
363
+ s.helloDone = true;
364
+ let resumed = null;
365
+ let rl = null;
366
+ const token = str(m, "resumeToken");
367
+ const entry = token !== null ? this.tokens.get(token) : undefined;
368
+ if (entry && entry.lobby.gameId === gameId && entry.lobby.netVersion === netVersion) {
369
+ // A token whose old socket is still open is a take-over: drop the old socket first (it detaches normally).
370
+ const old = entry.member.session;
371
+ if (old && old !== s)
372
+ this.closeSession(old);
373
+ if (entry.member.detachedAt > 0 && this.now() - entry.member.detachedAt < this.o.resumeWindowMs) {
374
+ resumed = entry.member;
375
+ rl = entry.lobby;
376
+ }
377
+ }
378
+ const w = {
379
+ t: "welcome",
380
+ ice: this.iceList(s),
381
+ serverTime: this.now(),
382
+ limits: { maxMsg: this.o.maxMessageBytes, maxPlayers: this.o.hardMaxPlayers },
383
+ };
384
+ if (resumed)
385
+ w.peerId = resumed.peerId;
386
+ this.send(s, w);
387
+ if (resumed && rl) {
388
+ this.tokens.delete(resumed.token);
389
+ resumed.token = hex(16);
390
+ this.tokens.set(resumed.token, { lobby: rl, member: resumed });
391
+ resumed.session = s;
392
+ resumed.detachedAt = 0;
393
+ s.lobby = rl;
394
+ s.member = resumed;
395
+ this.logEvent("resume", rl, s);
396
+ for (const other of rl.members)
397
+ if (other !== resumed)
398
+ this.sendTo(other, { t: "peer_joined", peer: this.peerObj(resumed) });
399
+ this.send(s, this.lobbyMsg(rl, resumed));
400
+ }
401
+ }
402
+ iceList(s) {
403
+ const ice = [];
404
+ if (this.o.stunUrls.length > 0)
405
+ ice.push({ urls: [...this.o.stunUrls] });
406
+ if (this.o.turnUrls.length > 0 && this.o.turnSecret.length > 0) {
407
+ const expiry = Math.floor(this.now() / 1000) + this.o.turnTtlSeconds;
408
+ const username = `${expiry}:${s.connId}`;
409
+ const credential = createHmac("sha1", this.o.turnSecret).update(username).digest("base64");
410
+ ice.push({ urls: [...this.o.turnUrls], username, credential, ttl: this.o.turnTtlSeconds });
411
+ }
412
+ return ice;
413
+ }
414
+ // ---------------------------------------------------------------- create / join / list / leave
415
+ ipOf(ip) {
416
+ let st = this.ips.get(ip);
417
+ if (!st) {
418
+ st = { create: new RateWindow(this.o.createPerMin * this.scale, 60_000), join: new RateWindow(this.o.joinPerMin * this.scale, 60_000), open: 0, sockets: 0 };
419
+ this.ips.set(ip, st);
420
+ }
421
+ return st;
422
+ }
423
+ create(s, m) {
424
+ if (s.lobby || s.pendingIn)
425
+ return this.error(s, "bad_request", "already in a lobby");
426
+ const ip = this.ipOf(s.ip);
427
+ const now = this.now();
428
+ if (!this.o.disableIpLimits) {
429
+ if (!ip.create.tryHit(now))
430
+ return this.rateLimited(s, "create", ip.create.retryAfterMs(now), null);
431
+ if (ip.open >= this.o.openLobbiesPerIp * this.scale)
432
+ return this.rateLimited(s, "open_lobbies", 5000, "max_lobbies");
433
+ }
434
+ if (this.o.singleLobby && this.createdOnce)
435
+ return this.error(s, "server_full", "this host runs one lobby");
436
+ if (this.lobbies.size >= this.o.maxLobbies || this.members >= this.o.maxPeers)
437
+ return this.error(s, "server_full", "server is at its lobby limit");
438
+ const quota = this.o.gameQuotas[s.gameId];
439
+ if (quota !== undefined && (this.gameLobbies.get(s.gameId) ?? 0) >= quota)
440
+ return this.error(s, "server_full", "lobby quota for this game is reached");
441
+ if (has(m, "name") && str(m, "name") === null)
442
+ return this.error(s, "bad_request", "name must be a string", field("name"));
443
+ const vis = str(m, "visibility") ?? "public";
444
+ const mode = str(m, "mode") ?? "lockstep";
445
+ if (vis !== "public" && vis !== "unlisted" && vis !== "private")
446
+ return this.error(s, "bad_request", "visibility must be public, unlisted or private", field("visibility"));
447
+ if (mode !== "lockstep" && mode !== "raw")
448
+ return this.error(s, "bad_request", "mode must be lockstep or raw", field("mode"));
449
+ if (has(m, "maxPlayers") && int(m, "maxPlayers") === null)
450
+ return this.error(s, "bad_request", "maxPlayers must be an integer", field("maxPlayers"));
451
+ const maxPlayers = int(m, "maxPlayers") ?? 8;
452
+ if (maxPlayers < 2 || maxPlayers > this.o.hardMaxPlayers)
453
+ return this.error(s, "bad_request", `maxPlayers must be 2-${this.o.hardMaxPlayers}`, field("maxPlayers"));
454
+ if (has(m, "tickRate") && int(m, "tickRate") === null)
455
+ return this.error(s, "bad_request", "tickRate must be an integer", field("tickRate"));
456
+ const tick = int(m, "tickRate") ?? 20;
457
+ if (tick < 1 || tick > 255)
458
+ return this.error(s, "bad_request", "tickRate must be 1-255", field("tickRate"));
459
+ if (has(m, "lateJoin") && bool(m, "lateJoin") === null)
460
+ return this.error(s, "bad_request", "lateJoin must be a boolean", field("lateJoin"));
461
+ if (has(m, "relayOnly") && bool(m, "relayOnly") === null)
462
+ return this.error(s, "bad_request", "relayOnly must be a boolean", field("relayOnly"));
463
+ const meta = readMeta(m.meta);
464
+ if ("err" in meta)
465
+ return this.error(s, "bad_request", meta.err, field("meta"));
466
+ const code = this.newCode();
467
+ if (code === null)
468
+ return this.error(s, "server_full", "could not allocate a join code");
469
+ const lobby = new HubLobby();
470
+ Object.assign(lobby, {
471
+ code, gameId: s.gameId, netVersion: s.netVersion, name: sanitize(str(m, "name"), LOBBY_NAME_MAX, `${s.name}'s lobby`),
472
+ visibility: vis, mode, tickRate: tick, lateJoin: bool(m, "lateJoin") ?? false, relayOnly: bool(m, "relayOnly") ?? false,
473
+ maxPlayers, createdAt: now, ownerIp: s.ip, meta: meta.meta,
474
+ });
475
+ this.lobbies.set(code, lobby);
476
+ this.createdOnce = true;
477
+ this.gameLobbies.set(s.gameId, (this.gameLobbies.get(s.gameId) ?? 0) + 1);
478
+ ip.open++;
479
+ const host = this.addMember(lobby, s, null);
480
+ lobby.hostId = host.peerId;
481
+ this.logEvent("create", lobby, s);
482
+ this.send(s, this.lobbyMsg(lobby, host));
483
+ }
484
+ join(s, m) {
485
+ if (s.lobby || s.pendingIn)
486
+ return this.error(s, "bad_request", "already in a lobby");
487
+ const ip = this.ipOf(s.ip);
488
+ const now = this.now();
489
+ if (!this.o.disableIpLimits && !ip.join.tryHit(now))
490
+ return this.rateLimited(s, "join", ip.join.retryAfterMs(now), null);
491
+ let lobby;
492
+ if (this.o.singleLobby && !has(m, "code")) {
493
+ // direct IP: the address is the invitation, so a join without a code means the one lobby
494
+ lobby = this.lobbies.values().next().value;
495
+ if (!lobby || lobby.gameId !== s.gameId)
496
+ return this.error(s, "unknown_code", "this host has no open lobby for that game");
497
+ }
498
+ else {
499
+ const code = normalizeCode(m.code);
500
+ if (code === null)
501
+ return this.error(s, "bad_request", "malformed join code", field("code"));
502
+ lobby = this.lobbies.get(code);
503
+ if (!lobby || lobby.gameId !== s.gameId)
504
+ return this.error(s, "unknown_code", "no lobby with that code");
505
+ }
506
+ if (this.o.password !== null && str(m, "password") !== this.o.password)
507
+ return this.error(s, "bad_password", "wrong password for this lobby");
508
+ if (lobby.netVersion !== s.netVersion)
509
+ return this.error(s, "version_mismatch", `host is on ${lobby.netVersion}, you are on ${s.netVersion}`, { hostNetVersion: lobby.netVersion, yourNetVersion: s.netVersion });
510
+ if (lobby.kicked.has(s.ip))
511
+ return this.error(s, "kicked", "you were removed from this lobby");
512
+ if (lobby.phase === "in_game" && !lobby.lateJoin)
513
+ return this.error(s, "in_game", "the game has already started");
514
+ if (lobby.members.length >= lobby.maxPlayers)
515
+ return this.error(s, "lobby_full", "lobby is full");
516
+ if (this.members >= this.o.maxPeers)
517
+ return this.error(s, "server_full", "server is at its peer limit");
518
+ if (lobby.visibility === "private") {
519
+ if (lobby.pending.length >= PENDING_MAX)
520
+ return this.error(s, "lobby_full", "too many pending requests");
521
+ const pm = new Member();
522
+ pm.peerId = lobby.nextPeerId++;
523
+ pm.name = s.name;
524
+ pm.session = s;
525
+ pm.ip = s.ip;
526
+ lobby.pending.push(pm);
527
+ s.pendingIn = lobby;
528
+ s.pendingMember = pm;
529
+ const hs = lobby.find(lobby.hostId)?.session;
530
+ if (hs)
531
+ this.send(hs, { t: "join_request", peer: this.peerObj(pm) });
532
+ this.logEvent("join_request", lobby, s);
533
+ return;
534
+ }
535
+ this.admit(lobby, s, null);
536
+ }
537
+ admit(lobby, s, pending) {
538
+ const joiner = this.addMember(lobby, s, pending);
539
+ this.logEvent("join", lobby, s);
540
+ for (const other of lobby.members)
541
+ if (other !== joiner)
542
+ this.sendTo(other, { t: "peer_joined", peer: this.peerObj(joiner) });
543
+ // A lobby whose host slot is only reserved (host dropped, nobody was connected) hands over to the first newcomer.
544
+ if (!lobby.find(lobby.hostId)?.session)
545
+ this.promoteHost(lobby, lobby.hostId);
546
+ this.send(s, this.lobbyMsg(lobby, joiner));
547
+ }
548
+ addMember(lobby, s, reuse) {
549
+ const mem = reuse ?? new Member();
550
+ if (!reuse)
551
+ mem.peerId = lobby.nextPeerId++;
552
+ mem.name = s.name;
553
+ mem.session = s;
554
+ mem.ip = s.ip;
555
+ mem.joinedAt = this.now();
556
+ mem.detachedAt = 0;
557
+ mem.token = hex(16);
558
+ lobby.members.push(mem);
559
+ this.members++;
560
+ this.tokens.set(mem.token, { lobby, member: mem });
561
+ s.lobby = lobby;
562
+ s.member = mem;
563
+ s.pendingIn = null;
564
+ s.pendingMember = null;
565
+ return mem;
566
+ }
567
+ list(s, m) {
568
+ const vis = str(m, "visibility");
569
+ if (!this.o.issueCodes)
570
+ return this.send(s, { t: "lobbies", items: [] });
571
+ if (vis !== "public")
572
+ return this.error(s, "bad_request", "only public lobbies can be listed", field("visibility"));
573
+ let limit = 50;
574
+ if (has(m, "limit")) {
575
+ const l = int(m, "limit");
576
+ if (l === null || l < 1 || l > this.o.listLimitMax)
577
+ return this.error(s, "bad_request", `limit must be 1-${this.o.listLimitMax}`, field("limit"));
578
+ limit = l;
579
+ }
580
+ let offset = 0;
581
+ if (has(m, "cursor")) {
582
+ const c = str(m, "cursor");
583
+ if (c === null || !/^\d+$/.test(c))
584
+ return this.error(s, "bad_request", "bad cursor", field("cursor"));
585
+ offset = Number(c);
586
+ }
587
+ let filter = null;
588
+ if (has(m, "meta")) {
589
+ if (typeof m.meta !== "object" || Array.isArray(m.meta))
590
+ return this.error(s, "bad_request", "meta must be an object", field("meta"));
591
+ filter = {};
592
+ for (const [k, v] of Object.entries(m.meta)) {
593
+ if (typeof v !== "string")
594
+ return this.error(s, "bad_request", "meta values must be strings", field("meta"));
595
+ filter[k] = v;
596
+ }
597
+ }
598
+ const all = [...this.lobbies.values()]
599
+ .filter((l) => l.gameId === s.gameId && l.netVersion === s.netVersion && l.visibility === "public" && (l.phase !== "in_game" || l.lateJoin)
600
+ && (filter === null || Object.entries(filter).every(([k, v]) => l.meta[k] === v)))
601
+ .sort((a, b) => a.createdAt - b.createdAt || byCode(a.code, b.code));
602
+ const page = all.slice(offset, offset + limit);
603
+ const items = page.map((l) => ({
604
+ code: l.code, gameId: l.gameId, netVersion: l.netVersion, name: l.name, hostName: l.find(l.hostId)?.name ?? "",
605
+ players: l.members.length, maxPlayers: l.maxPlayers, phase: l.phase, meta: { ...l.meta }, region: this.o.region, createdAt: l.createdAt,
606
+ }));
607
+ const reply = { t: "lobbies", items };
608
+ if (offset + page.length < all.length)
609
+ reply.cursor = String(offset + page.length);
610
+ this.send(s, reply);
611
+ }
612
+ leave(s) {
613
+ if (s.pendingIn) {
614
+ s.pendingIn.pending = s.pendingIn.pending.filter((p) => p !== s.pendingMember);
615
+ s.pendingIn = null;
616
+ s.pendingMember = null;
617
+ return;
618
+ }
619
+ if (!s.lobby || !s.member)
620
+ return;
621
+ this.removeMember(s.lobby, s.member, "left");
622
+ }
623
+ // ---------------------------------------------------------------- relay + host-only
624
+ signal(s, m) {
625
+ const lobby = s.lobby;
626
+ const me = s.member;
627
+ if (!lobby || !me)
628
+ return this.error(s, "bad_request", "not in a lobby");
629
+ const now = this.now();
630
+ if (!this.o.disableIpLimits && !s.signalRate.tryHit(now))
631
+ return this.rateLimited(s, "signal", s.signalRate.retryAfterMs(now), null);
632
+ const to = int(m, "to");
633
+ const target = to === null ? undefined : lobby.find(to);
634
+ if (!target || !target.session || target === me)
635
+ return this.error(s, "bad_request", "unknown signal target", field("to"));
636
+ if (!has(m, "sdp") && !has(m, "ice"))
637
+ return this.error(s, "bad_request", "signal needs sdp or ice", field("sdp"));
638
+ const o = { t: "signal", from: me.peerId };
639
+ if (has(m, "sdp"))
640
+ o.sdp = m.sdp;
641
+ if (has(m, "ice"))
642
+ o.ice = m.ice;
643
+ this.send(target.session, o);
644
+ }
645
+ requireHost(s) {
646
+ if (!s.lobby || !s.member) {
647
+ this.error(s, "bad_request", "not in a lobby");
648
+ return null;
649
+ }
650
+ if (s.lobby.hostId !== s.member.peerId) {
651
+ this.error(s, "not_host", "only the host can do that");
652
+ return null;
653
+ }
654
+ return s.lobby;
655
+ }
656
+ hostMeta(s, m) {
657
+ const lobby = this.requireHost(s);
658
+ if (!lobby)
659
+ return;
660
+ const meta = readMeta(m.meta);
661
+ if ("err" in meta || !has(m, "meta"))
662
+ return this.error(s, "bad_request", "err" in meta ? meta.err : "meta is required", field("meta"));
663
+ lobby.meta = meta.meta;
664
+ this.broadcastLobby(lobby);
665
+ }
666
+ hostPhase(s, m) {
667
+ const lobby = this.requireHost(s);
668
+ if (!lobby)
669
+ return;
670
+ const ph = str(m, "phase");
671
+ if (ph !== "in_game" && ph !== "lobby")
672
+ return this.error(s, "bad_request", "phase must be in_game or lobby", field("phase"));
673
+ lobby.phase = ph;
674
+ this.logEvent(`phase:${ph}`, lobby, s);
675
+ this.broadcastLobby(lobby);
676
+ }
677
+ kick(s, m) {
678
+ const lobby = this.requireHost(s);
679
+ if (!lobby)
680
+ return;
681
+ const id = int(m, "peerId");
682
+ const target = id === null ? undefined : lobby.find(id);
683
+ if (id === null || id === s.member.peerId || !target)
684
+ return this.error(s, "bad_request", "unknown peerId", field("peerId"));
685
+ lobby.kicked.add(target.ip);
686
+ this.removeMember(lobby, target, "kicked");
687
+ }
688
+ approve(s, m) {
689
+ const lobby = this.requireHost(s);
690
+ if (!lobby)
691
+ return;
692
+ const id = int(m, "peerId");
693
+ const pm = id === null ? undefined : lobby.pending.find((p) => p.peerId === id);
694
+ const js = pm?.session;
695
+ if (!pm || !js)
696
+ return this.error(s, "bad_request", "no such pending request", field("peerId"));
697
+ if (has(m, "ok") && bool(m, "ok") === null)
698
+ return this.error(s, "bad_request", "ok must be a boolean", field("ok"));
699
+ lobby.pending = lobby.pending.filter((p) => p !== pm);
700
+ js.pendingIn = null;
701
+ js.pendingMember = null;
702
+ if (bool(m, "ok") !== true)
703
+ return this.error(js, "kicked", "the host declined your request", { reason: "denied" });
704
+ if (lobby.members.length >= lobby.maxPlayers)
705
+ return this.error(js, "lobby_full", "lobby is full");
706
+ if (lobby.phase === "in_game" && !lobby.lateJoin)
707
+ return this.error(js, "in_game", "the game has already started");
708
+ this.admit(lobby, js, pm);
709
+ }
710
+ /** Peer-side migration (s.9) tells the server: the new host announces itself with a higher epoch. */
711
+ claimHost(s, m) {
712
+ const lobby = s.lobby;
713
+ const me = s.member;
714
+ if (!lobby || !me)
715
+ return;
716
+ const host = int(m, "hostId");
717
+ const epoch = int(m, "epoch");
718
+ if (host !== me.peerId || epoch === null || epoch <= lobby.epoch)
719
+ return;
720
+ lobby.hostId = me.peerId;
721
+ lobby.epoch = epoch;
722
+ this.broadcast(lobby, { t: "host_changed", hostId: lobby.hostId, epoch: lobby.epoch });
723
+ this.logEvent("host_claim", lobby, s);
724
+ }
725
+ pathReport(s, m) {
726
+ const p = str(m, "path");
727
+ if (p !== "relayed" && p !== "direct")
728
+ this.error(s, "bad_request", "path must be relayed or direct", field("path"));
729
+ }
730
+ // ---------------------------------------------------------------- membership changes
731
+ /** Removes a member for good. A member that is already detached was announced as left, so nothing is sent for it. */
732
+ removeMember(lobby, mem, reason) {
733
+ const i = lobby.members.indexOf(mem);
734
+ if (i < 0)
735
+ return;
736
+ lobby.members.splice(i, 1);
737
+ this.members--;
738
+ this.tokens.delete(mem.token);
739
+ const ms = mem.session;
740
+ const wasConnected = mem.detachedAt === 0;
741
+ if (ms) {
742
+ ms.lobby = null;
743
+ ms.member = null;
744
+ }
745
+ if (wasConnected) {
746
+ this.broadcast(lobby, { t: "peer_left", peerId: mem.peerId, reason });
747
+ if (reason === "kicked" && ms)
748
+ this.error(ms, "kicked", "the host removed you from the lobby");
749
+ }
750
+ this.logEvent(reason, lobby, ms);
751
+ if (lobby.members.length === 0)
752
+ return this.closeLobby(lobby);
753
+ if (lobby.hostId === mem.peerId)
754
+ this.promoteHost(lobby, mem.peerId);
755
+ }
756
+ /** Server mirror of the peer-side rule (s.9): new host is the lowest peerId still connected; epoch +1. */
757
+ promoteHost(lobby, leavingId) {
758
+ let next = lobby.members.filter((x) => x.session && x.peerId !== leavingId).sort((a, b) => a.peerId - b.peerId)[0];
759
+ if (!next) {
760
+ // Nobody connected. Keep a reserved host slot (it may resume); otherwise fall back to the lowest slot.
761
+ if (lobby.find(leavingId))
762
+ return;
763
+ next = [...lobby.members].sort((a, b) => a.peerId - b.peerId)[0];
764
+ if (!next)
765
+ return;
766
+ }
767
+ lobby.hostId = next.peerId;
768
+ lobby.epoch++;
769
+ this.broadcast(lobby, { t: "host_changed", hostId: next.peerId, epoch: lobby.epoch });
770
+ const hs = next.session;
771
+ if (hs)
772
+ for (const pm of lobby.pending)
773
+ this.send(hs, { t: "join_request", peer: this.peerObj(pm) }); // the new host inherits the pending requests
774
+ this.logEvent("host_changed", lobby, next.session);
775
+ }
776
+ closeLobby(lobby) {
777
+ this.lobbies.delete(lobby.code);
778
+ this.retired.set(lobby.code, this.now());
779
+ const n = this.gameLobbies.get(lobby.gameId);
780
+ if (n !== undefined) {
781
+ if (n <= 1)
782
+ this.gameLobbies.delete(lobby.gameId);
783
+ else
784
+ this.gameLobbies.set(lobby.gameId, n - 1);
785
+ }
786
+ const ip = this.ips.get(lobby.ownerIp);
787
+ if (ip && ip.open > 0)
788
+ ip.open--;
789
+ for (const pm of [...lobby.pending]) {
790
+ const ps = pm.session;
791
+ if (ps) {
792
+ ps.pendingIn = null;
793
+ ps.pendingMember = null;
794
+ this.error(ps, "unknown_code", "the lobby closed");
795
+ }
796
+ }
797
+ lobby.pending = [];
798
+ this.logEvent("close", lobby, null);
799
+ }
800
+ newCode() {
801
+ for (let i = 0; i < 100; i++) {
802
+ let c = "";
803
+ for (let k = 0; k < 6; k++)
804
+ c += CROCKFORD[this.rand(32)];
805
+ if (!this.lobbies.has(c) && !this.retired.has(c))
806
+ return c;
807
+ }
808
+ return null;
809
+ }
810
+ // ---------------------------------------------------------------- output helpers
811
+ lobbyMsg(l, you) {
812
+ const peers = l.members
813
+ .filter((x) => x.detachedAt === 0)
814
+ .sort((a, b) => a.peerId - b.peerId)
815
+ .map((p) => ({ peerId: p.peerId, name: p.name, joinedAt: p.joinedAt }));
816
+ return {
817
+ t: "lobby", code: this.o.issueCodes ? l.code : "", gameId: l.gameId, netVersion: l.netVersion, hostId: l.hostId, you: you.peerId,
818
+ mode: l.mode, tickRate: l.tickRate, lateJoin: l.lateJoin, phase: l.phase, meta: { ...l.meta }, peers, resumeToken: you.token,
819
+ name: l.name, maxPlayers: l.maxPlayers, visibility: l.visibility, relayOnly: l.relayOnly,
820
+ };
821
+ }
822
+ broadcastLobby(l) {
823
+ for (const m of l.members)
824
+ if (m.session)
825
+ this.sendTo(m, this.lobbyMsg(l, m));
826
+ }
827
+ broadcast(l, o) {
828
+ for (const m of l.members)
829
+ this.sendTo(m, o);
830
+ }
831
+ peerObj(m) {
832
+ return { peerId: m.peerId, name: m.name };
833
+ }
834
+ send(s, o) {
835
+ if (!s.closed)
836
+ s.send(JSON.stringify(o));
837
+ }
838
+ sendTo(m, o) {
839
+ if (m.session)
840
+ this.send(m.session, o);
841
+ }
842
+ error(s, code, msg, data) {
843
+ const o = { t: "error", code, msg };
844
+ if (data !== undefined)
845
+ o.data = data;
846
+ s.send(JSON.stringify(o));
847
+ }
848
+ rateLimited(s, kind, retryAfterMs, reason) {
849
+ this.log(`rate_limit kind=${kind} ip=${this.hashIp(s.ip)}`);
850
+ const data = { retryAfterMs };
851
+ if (reason !== null)
852
+ data.reason = reason;
853
+ this.error(s, "rate_limited", `too many ${kind} requests`, data);
854
+ }
855
+ logEvent(ev, l, s) {
856
+ this.log(`lobby ${ev} code=${l.code} game=${l.gameId} players=${l.members.length} ip=${s ? this.hashIp(s.ip) : "-"}`);
857
+ }
858
+ hashIp(ip) {
859
+ return createHash("sha256").update(this.logSalt + ip).digest("hex").slice(0, 8);
860
+ }
861
+ }
862
+ //# sourceMappingURL=hub.js.map