onelobby 0.1.0 → 0.2.1

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.
@@ -1,11 +1,11 @@
1
1
  import { type Clock } from "./clock.js";
2
2
  import { Emitter } from "./emitter.js";
3
- import { Lobby } from "./lobby.js";
3
+ import { Lobby, type RateLimit } from "./lobby.js";
4
4
  import { type LobbySummary } from "./signaling.js";
5
5
  import { type LinkFactory, type SocketFactory } from "./transport.js";
6
6
  import type { VoiceCodec, FrameSource, AudioSink } from "./voice-types.js";
7
7
  export declare const LIB_NAME = "onelobby";
8
- export declare const LIB_VERSION = "0.1.0";
8
+ export declare const LIB_VERSION = "0.2.1";
9
9
  /** Where `rememberSeat` keeps the resume token: the subset of the Web Storage API it uses. */
10
10
  export interface SeatStorage {
11
11
  getItem(key: string): string | null;
@@ -44,6 +44,13 @@ export interface OneLobbyOptions {
44
44
  * Default 15000; 0 waits forever.
45
45
  */
46
46
  readyTimeoutMs?: number;
47
+ /**
48
+ * Treat a peer as gone after this many ms of silence. Joiners ping the host every 2 s and the host answers, so the
49
+ * host drops a silent joiner (`peerLeft`, "timeout") and a joiner handles a silent host as lost (a direct lobby
50
+ * ends with "host_left"). Default 0 (off): a silently dead peer then surfaces when its link or signaling socket
51
+ * times out, within about 30 s. Values under 6000 are raised to 6000.
52
+ */
53
+ peerTimeoutMs?: number;
47
54
  /**
48
55
  * Keep this tab's seat across a page reload (sessionStorage): `join(code)` and `resume()` take the seat back
49
56
  * instead of joining as a new player. Cleared when the session ends. Default true where sessionStorage exists.
@@ -76,6 +83,8 @@ export interface HostOptions {
76
83
  visibility?: "public" | "unlisted" | "private";
77
84
  lateJoin?: boolean;
78
85
  meta?: Record<string, string>;
86
+ /** Per-joiner cap on incoming raw messages, e.g. `{ messages: 60, perMs: 1000 }`; the excess is dropped before `message` fires or is relayed. */
87
+ rateLimit?: RateLimit;
79
88
  }
80
89
  export interface ListOptions {
81
90
  meta?: Record<string, string>;
@@ -102,7 +111,7 @@ export type OneLobbyEvents = {
102
111
  export declare const DEFAULT_READY_TIMEOUT_MS = 15000;
103
112
  /** Entry point (DESIGN s.11.2). */
104
113
  export declare class OneLobby extends Emitter<OneLobbyEvents> {
105
- readonly options: Required<Pick<OneLobbyOptions, "gameId" | "netVersion" | "lobbyServer" | "mode" | "tickRate" | "hashEveryTicks" | "displayName" | "relayOnly" | "readyTimeoutMs">>;
114
+ readonly options: Required<Pick<OneLobbyOptions, "gameId" | "netVersion" | "lobbyServer" | "mode" | "tickRate" | "hashEveryTicks" | "displayName" | "relayOnly" | "readyTimeoutMs" | "peerTimeoutMs">>;
106
115
  private socketFactory;
107
116
  private linkFactory;
108
117
  private clock;
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.1.0 | MIT license */
1
+ /*! onelobby 0.2.1 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -33,10 +33,12 @@ var OneLobby = (() => {
33
33
  Lobby: () => Lobby,
34
34
  LobbyController: () => LobbyController,
35
35
  Lockstep: () => Lockstep,
36
+ MIN_PEER_TIMEOUT_MS: () => MIN_PEER_TIMEOUT_MS,
36
37
  OneLobby: () => OneLobby,
37
38
  OneLobbyError: () => OneLobbyError,
38
39
  SharedState: () => SharedState,
39
40
  Voice: () => Voice,
41
+ closeCodeFor: () => closeCodeFor,
40
42
  codeFromUrl: () => codeFromUrl,
41
43
  decodeJoinCode: () => decodeJoinCode,
42
44
  defineLobbyElement: () => defineLobbyElement,
@@ -1622,7 +1624,141 @@ var OneLobby = (() => {
1622
1624
  static END = FLAG_END_OF_BURST;
1623
1625
  };
1624
1626
 
1627
+ // src/socket.ts
1628
+ function closeCodeFor(reason) {
1629
+ if (reason === "left" || reason === "disconnected") return 1e3;
1630
+ if (reason === "host_left" || reason === "closed") return 1001;
1631
+ if (reason === "kicked") return 4e3;
1632
+ return 1006;
1633
+ }
1634
+ var nextTask = globalThis.setImmediate ?? ((f) => void setTimeout(f, 0));
1635
+ var enc2 = new TextEncoder();
1636
+ var dec2 = new TextDecoder();
1637
+ var PeerSocket = class {
1638
+ constructor(peerId, out, binary, onLocalClose) {
1639
+ this.peerId = peerId;
1640
+ this.out = out;
1641
+ this.binary = binary;
1642
+ this.onLocalClose = onLocalClose;
1643
+ }
1644
+ peerId;
1645
+ out;
1646
+ binary;
1647
+ onLocalClose;
1648
+ readyState = 0;
1649
+ bufferedAmount = 0;
1650
+ openHandler = null;
1651
+ /** OPEN, but `onopen` has not run yet for any handler. */
1652
+ openPending = false;
1653
+ onmessage = null;
1654
+ onclose = null;
1655
+ onerror = null;
1656
+ /** Unsubscribes from the lobby; set by the factory. */
1657
+ detach = () => {
1658
+ };
1659
+ send(data) {
1660
+ if (this.readyState !== 1) return;
1661
+ this.out(typeof data === "string" ? enc2.encode(data) : data);
1662
+ }
1663
+ close(code = 1e3, reason = "left") {
1664
+ if (this.readyState === 3) return;
1665
+ this.closed(code, reason);
1666
+ this.onLocalClose();
1667
+ }
1668
+ get onopen() {
1669
+ return this.openHandler;
1670
+ }
1671
+ set onopen(h) {
1672
+ this.openHandler = h;
1673
+ if (h && this.openPending) nextTask(() => this.fireOpen());
1674
+ }
1675
+ /**
1676
+ * @internal OPEN now; `onopen` now, or on a later task (like a real WebSocket) so a handler assigned
1677
+ * after any number of awaits in the same task still runs.
1678
+ */
1679
+ open(later = false) {
1680
+ if (this.readyState !== 0) return;
1681
+ this.readyState = 1;
1682
+ this.openPending = true;
1683
+ if (!later) this.fireOpen();
1684
+ else nextTask(() => this.fireOpen());
1685
+ }
1686
+ fireOpen() {
1687
+ if (this.readyState !== 1 || !this.openPending || !this.openHandler) return;
1688
+ this.openPending = false;
1689
+ this.openHandler({ type: "open" });
1690
+ }
1691
+ /** @internal */
1692
+ deliver(bytes) {
1693
+ if (this.readyState === 1) this.onmessage?.({ type: "message", data: this.binary ? bytes : dec2.decode(bytes) });
1694
+ }
1695
+ /** @internal */
1696
+ closed(code, reason) {
1697
+ if (this.readyState === 3) return;
1698
+ this.readyState = 3;
1699
+ this.detach();
1700
+ this.onclose?.({ type: "close", code, reason, wasClean: code === 1e3 || code === 1001 });
1701
+ }
1702
+ };
1703
+ function joinerSocket(lobby, o = {}) {
1704
+ if (lobby.isHost) throw new Error("asWebSocket() is for joiners; the host uses lobby.onConnection()");
1705
+ const channel = o.channel ?? "reliable";
1706
+ const s = new PeerSocket(lobby.hostPeerId, (b) => lobby.send(lobby.hostPeerId, b, { channel }), !!o.binary, () => void lobby.leave());
1707
+ const offs = [
1708
+ lobby.on("message", (from, bytes) => {
1709
+ if (from === lobby.hostPeerId) s.deliver(bytes);
1710
+ }),
1711
+ lobby.on("sessionEnded", (reason, close) => s.closed(close?.code ?? closeCodeFor(reason), close?.reason ?? reason)),
1712
+ // The socket's far end is the host it opened to; after a migration that server is gone (direct lobbies end instead).
1713
+ lobby.on("hostChanged", () => s.closed(1001, "host_changed"))
1714
+ ];
1715
+ s.detach = () => offs.forEach((f) => f());
1716
+ if (lobby.connectedPeers.includes(lobby.hostPeerId)) s.open(true);
1717
+ else lobby.ready.then(() => s.open(true), () => {
1718
+ });
1719
+ return s;
1720
+ }
1721
+ function hostConnections(lobby, handler, o = {}) {
1722
+ if (!lobby.isHost) throw new Error("onConnection() is for the host; a joiner uses lobby.asWebSocket()");
1723
+ const channel = o.channel ?? "reliable";
1724
+ const conns = /* @__PURE__ */ new Map();
1725
+ let stopped = false;
1726
+ const ensure = (peerId) => {
1727
+ if (peerId === lobby.localPeerId) return void 0;
1728
+ let c = conns.get(peerId);
1729
+ if (c || stopped) return c;
1730
+ c = new PeerSocket(peerId, (b) => lobby.send(peerId, b, { channel }), !!o.binary, () => {
1731
+ });
1732
+ const conn = c;
1733
+ conn.detach = () => {
1734
+ if (conns.get(peerId) === conn) conns.delete(peerId);
1735
+ };
1736
+ conns.set(peerId, conn);
1737
+ conn.open();
1738
+ handler(conn);
1739
+ return conn;
1740
+ };
1741
+ const offs = [
1742
+ lobby.on("peerReady", (p) => void ensure(p.peerId)),
1743
+ lobby.on("message", (from, bytes) => ensure(from)?.deliver(bytes)),
1744
+ lobby.on(
1745
+ "peerLeft",
1746
+ (p, reason, close) => conns.get(p.peerId)?.closed(close?.code ?? (reason === "left" ? 1001 : closeCodeFor(reason)), close?.reason ?? reason)
1747
+ ),
1748
+ lobby.on("sessionEnded", (reason) => {
1749
+ for (const c of [...conns.values()]) c.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason);
1750
+ offs.forEach((f) => f());
1751
+ })
1752
+ ];
1753
+ for (const id of lobby.connectedPeers) ensure(id);
1754
+ return () => {
1755
+ stopped = true;
1756
+ };
1757
+ }
1758
+
1625
1759
  // src/lobby.ts
1760
+ var MIN_PEER_TIMEOUT_MS = 6e3;
1761
+ var KICK_LINGER_MS = 3e3;
1626
1762
  function seatName(p) {
1627
1763
  const n = typeof p.name === "string" ? p.name.trim() : "";
1628
1764
  if (n && n !== "Player") return p;
@@ -1682,10 +1818,16 @@ var OneLobby = (() => {
1682
1818
  isReady = false;
1683
1819
  cancelReadyTimer = null;
1684
1820
  relayRetry = false;
1821
+ /** clock.now() of the last frame from each peer (peerTimeoutMs). */
1822
+ lastHeard = /* @__PURE__ */ new Map();
1823
+ /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
1824
+ lingering = /* @__PURE__ */ new Set();
1825
+ /** Host: per-peer fixed window for rateLimit. */
1826
+ rate = /* @__PURE__ */ new Map();
1685
1827
  /**
1686
1828
  * Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
1687
1829
  * joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
1688
- * (or with "closed"/"kicked" when the lobby ends first).
1830
+ * (or with "closed"/"kicked" when the lobby ends first; "closed" also when the host disconnected it).
1689
1831
  */
1690
1832
  ready = new Promise((res, rej) => {
1691
1833
  this.markReady = () => {
@@ -1765,6 +1907,21 @@ var OneLobby = (() => {
1765
1907
  peer(peerId) {
1766
1908
  return this.roster.get(peerId);
1767
1909
  }
1910
+ /** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
1911
+ get connectedPeers() {
1912
+ return this.connectedPeerIds();
1913
+ }
1914
+ /**
1915
+ * Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
1916
+ * unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
1917
+ */
1918
+ asWebSocket(opts) {
1919
+ return joinerSocket(this, opts);
1920
+ }
1921
+ /** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
1922
+ onConnection(handler, opts) {
1923
+ return hostConnections(this, handler, opts);
1924
+ }
1768
1925
  set snapshotChunkSize(n) {
1769
1926
  this.lockstep.chunkSize = n;
1770
1927
  }
@@ -1798,10 +1955,32 @@ var OneLobby = (() => {
1798
1955
  this.signaling.send({ t: "meta", meta });
1799
1956
  this.emit("metaChanged", this.meta);
1800
1957
  }
1801
- async kick(peerId) {
1958
+ /**
1959
+ * Host: removes a player. By default it is a ban: the hub refuses that player's address for the rest of this lobby
1960
+ * (a new tab or a reload included). `{ ban: false }` is `disconnect()`. `code` / `reason` reach the player's
1961
+ * `asWebSocket()` onclose (and `sessionEnded`'s second argument); without them its socket closes with 4000 "kicked".
1962
+ */
1963
+ async kick(peerId, opts = {}) {
1802
1964
  if (!this.isHost) throw new Error("only the host can kick");
1803
- this.signaling.send({ t: "kick", peerId });
1804
- this.removePeer(peerId, "kicked");
1965
+ const { ban = true, code, reason } = opts;
1966
+ if (code !== void 0 && !(code === 1e3 || Number.isInteger(code) && code >= 3e3 && code <= 4999))
1967
+ throw new RangeError("close code must be 1000 or 3000-4999");
1968
+ if (reason !== void 0 && reason.length > 120) throw new RangeError("close reason must be at most 120 characters");
1969
+ const msg = { t: "kick", peerId };
1970
+ if (!ban) msg.ban = false;
1971
+ if (code !== void 0) msg.closeCode = code;
1972
+ if (reason !== void 0) msg.closeReason = reason;
1973
+ this.signaling.send(msg);
1974
+ const close = code !== void 0 || reason !== void 0 ? { code, reason } : void 0;
1975
+ this.removePeer(peerId, ban ? "kicked" : "disconnected", close, true);
1976
+ }
1977
+ /**
1978
+ * Host: ends one player's connection without a ban, like a server closing one WebSocket. The player's
1979
+ * `asWebSocket()` gets onclose with `code` (default 1000) and `reason` (default "disconnected"), its lobby ends with
1980
+ * `sessionEnded("disconnected")`, and it may join again (a new tab, a reload). Same as `kick(peerId, { ban: false, code, reason })`.
1981
+ */
1982
+ disconnect(peerId, code, reason) {
1983
+ return this.kick(peerId, { ban: false, code, reason });
1805
1984
  }
1806
1985
  async approve(peerId, ok) {
1807
1986
  if (!this.isHost) throw new Error("only the host can approve joins");
@@ -1972,6 +2151,7 @@ var OneLobby = (() => {
1972
2151
  return;
1973
2152
  }
1974
2153
  if (!d) return;
2154
+ if (!reassembled) this.lastHeard.set(from, this.cfg.clock.now());
1975
2155
  const { msg, route } = d;
1976
2156
  switch (msg.type) {
1977
2157
  case "hi":
@@ -2056,6 +2236,7 @@ var OneLobby = (() => {
2056
2236
  this.emit("message", route, bytes, channel);
2057
2237
  return;
2058
2238
  }
2239
+ if (!this.allowRaw(from)) return;
2059
2240
  if (route === 0 || route === this.localPeerId) {
2060
2241
  this.emit("message", from, bytes, channel);
2061
2242
  }
@@ -2065,6 +2246,14 @@ var OneLobby = (() => {
2065
2246
  this.sendTo(route, { type: "raw", bytes }, from, channel);
2066
2247
  }
2067
2248
  }
2249
+ allowRaw(from) {
2250
+ const lim = this.cfg.rateLimit;
2251
+ if (!lim || !(lim.messages > 0) || !(lim.perMs > 0)) return true;
2252
+ const now = this.cfg.clock.now();
2253
+ let w = this.rate.get(from);
2254
+ if (!w || now - w.start >= lim.perMs) this.rate.set(from, w = { start: now, n: 0 });
2255
+ return ++w.n <= lim.messages;
2256
+ }
2068
2257
  // ---- migration -------------------------------------------------------------------------------------------------
2069
2258
  setHost(hostId) {
2070
2259
  if (hostId === this.hostPeerId) return;
@@ -2101,6 +2290,7 @@ var OneLobby = (() => {
2101
2290
  this.markReady();
2102
2291
  this.connected.clear();
2103
2292
  this.hiSentTo.clear();
2293
+ this.lastHeard.clear();
2104
2294
  this.lockstep.becomeHost();
2105
2295
  this.emit("hostChanged", this.localPeerId);
2106
2296
  if (this.serverUp) this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
@@ -2191,8 +2381,13 @@ var OneLobby = (() => {
2191
2381
  void this.onSignal(Number(m.from), m);
2192
2382
  break;
2193
2383
  case "error":
2194
- if (m.code === "kicked") this.teardown("kicked");
2195
- else this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
2384
+ if (m.code === "kicked") {
2385
+ const d = m.data ?? {};
2386
+ const close = {};
2387
+ if (typeof d.closeCode === "number") close.code = d.closeCode;
2388
+ if (typeof d.closeReason === "string") close.reason = d.closeReason;
2389
+ this.teardown(d.ban === false ? "disconnected" : "kicked", close.code !== void 0 || close.reason !== void 0 ? close : void 0);
2390
+ } else this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
2196
2391
  break;
2197
2392
  }
2198
2393
  }
@@ -2207,7 +2402,12 @@ var OneLobby = (() => {
2207
2402
  if (s.ice) payload.ice = s.ice;
2208
2403
  await link.handleSignal(payload);
2209
2404
  }
2210
- removePeer(peerId, reason) {
2405
+ /**
2406
+ * `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
2407
+ * or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
2408
+ * like the host vanished (a migration and a 1001 instead of the host's close code).
2409
+ */
2410
+ removePeer(peerId, reason, close, linger = false) {
2211
2411
  const peer = this.roster.get(peerId);
2212
2412
  if (peerId === this.localPeerId) {
2213
2413
  this.teardown(reason === "kicked" ? "kicked" : reason);
@@ -2216,25 +2416,40 @@ var OneLobby = (() => {
2216
2416
  if (peer) this.roster.delete(peerId);
2217
2417
  const link = this.links.get(peerId);
2218
2418
  if (link && peerId !== this.hostPeerId) {
2219
- link.onClose = null;
2220
2419
  link.onMessage = null;
2221
- link.close();
2222
2420
  this.links.delete(peerId);
2223
2421
  this.hiSentTo.delete(link);
2422
+ if (linger) {
2423
+ this.lingering.add(link);
2424
+ const done = () => {
2425
+ if (!this.lingering.delete(link)) return;
2426
+ cancel();
2427
+ link.onClose = null;
2428
+ link.close();
2429
+ };
2430
+ const cancel = this.cfg.clock.setTimeout(done, KICK_LINGER_MS);
2431
+ link.onClose = done;
2432
+ } else {
2433
+ link.onClose = null;
2434
+ link.close();
2435
+ }
2224
2436
  }
2225
2437
  this.connected.delete(peerId);
2226
2438
  this.outbox.delete(peerId);
2439
+ this.lastHeard.delete(peerId);
2440
+ this.rate.delete(peerId);
2227
2441
  this.lockstep.onPeerLeft(peerId);
2228
2442
  this.chat.onPeerLeft(peerId);
2229
2443
  this.voice.onPeerLeft(peerId);
2230
- if (peer) this.emit("peerLeft", peer, reason);
2444
+ if (peer) this.emit("peerLeft", peer, reason, close);
2231
2445
  if (peerId === this.hostPeerId && !this.isHost) {
2232
2446
  this.handleHostLost(reason);
2233
2447
  }
2234
2448
  }
2449
+ /** Lockstep drops (stalled, desynced): removed without a ban, so the player can come back (a rejoin resyncs). */
2235
2450
  dropPeer(peerId, reason) {
2236
- this.signaling.send({ t: "kick", peerId });
2237
- this.removePeer(peerId, reason);
2451
+ this.signaling.send({ t: "kick", peerId, ban: false, closeReason: reason });
2452
+ this.removePeer(peerId, reason, void 0, true);
2238
2453
  }
2239
2454
  setPhase(p) {
2240
2455
  if (p !== "lobby" && p !== "in_game") return;
@@ -2255,6 +2470,9 @@ var OneLobby = (() => {
2255
2470
  }
2256
2471
  // ---- housekeeping ----------------------------------------------------------------------------------------------
2257
2472
  watchdog() {
2473
+ if (this.ended) return;
2474
+ const timeout = this.cfg.peerTimeoutMs ?? 0;
2475
+ if (timeout > 0) this.dropSilent(Math.max(timeout, MIN_PEER_TIMEOUT_MS));
2258
2476
  if (this.ended || this.isHost) return;
2259
2477
  if (this.mode === "lockstep" && this.lockstep.hostSilent()) {
2260
2478
  this.lockstep.touch();
@@ -2262,11 +2480,27 @@ var OneLobby = (() => {
2262
2480
  this.handleHostLost("timeout");
2263
2481
  }
2264
2482
  }
2483
+ /** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
2484
+ dropSilent(timeout) {
2485
+ const now = this.cfg.clock.now();
2486
+ if (this.isHost) {
2487
+ for (const id of [...this.connected]) {
2488
+ const last = this.lastHeard.get(id);
2489
+ if (last !== void 0 && now - last > timeout) this.removePeer(id, "timeout");
2490
+ }
2491
+ } else if (this.isReady) {
2492
+ const last = this.lastHeard.get(this.hostPeerId);
2493
+ if (last !== void 0 && now - last > timeout) {
2494
+ this.lastHeard.delete(this.hostPeerId);
2495
+ this.handleHostLost("timeout");
2496
+ }
2497
+ }
2498
+ }
2265
2499
  pingHost() {
2266
2500
  if (this.ended || this.isHost) return;
2267
2501
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
2268
2502
  }
2269
- teardown(reason) {
2503
+ teardown(reason, close) {
2270
2504
  if (this.ended) return;
2271
2505
  this.ended = true;
2272
2506
  this.cancelReadyTimer?.();
@@ -2278,16 +2512,17 @@ var OneLobby = (() => {
2278
2512
  this.cancelPing?.();
2279
2513
  this.lockstep.stop();
2280
2514
  this.voice.close();
2281
- for (const l of this.links.values()) {
2515
+ for (const l of [...this.links.values(), ...this.lingering]) {
2282
2516
  l.onClose = null;
2283
2517
  l.onMessage = null;
2284
2518
  l.close();
2285
2519
  }
2286
2520
  this.links.clear();
2521
+ this.lingering.clear();
2287
2522
  this.signaling.onClose = null;
2288
2523
  this.signaling.onMessage = null;
2289
2524
  this.signaling.close();
2290
- this.emit("sessionEnded", reason);
2525
+ this.emit("sessionEnded", reason, close);
2291
2526
  }
2292
2527
  /** Total in-flight links (diagnostics and tests). */
2293
2528
  get linkCount() {
@@ -2306,7 +2541,7 @@ var OneLobby = (() => {
2306
2541
  var webCodecsOpus = {
2307
2542
  createEncoder(opts, onFrame) {
2308
2543
  let ts = 0;
2309
- const enc3 = new AudioEncoder({
2544
+ const enc4 = new AudioEncoder({
2310
2545
  output: (chunk) => {
2311
2546
  const out = new Uint8Array(chunk.byteLength);
2312
2547
  chunk.copyTo(out);
@@ -2314,7 +2549,7 @@ var OneLobby = (() => {
2314
2549
  },
2315
2550
  error: (e) => console.error("onelobby: AudioEncoder", e)
2316
2551
  });
2317
- enc3.configure({
2552
+ enc4.configure({
2318
2553
  codec: "opus",
2319
2554
  sampleRate: SAMPLE_RATE,
2320
2555
  numberOfChannels: 1,
@@ -2332,17 +2567,17 @@ var OneLobby = (() => {
2332
2567
  data: pcm
2333
2568
  });
2334
2569
  ts += 2e4;
2335
- enc3.encode(data);
2570
+ enc4.encode(data);
2336
2571
  data.close();
2337
2572
  },
2338
2573
  close() {
2339
- if (enc3.state !== "closed") enc3.close();
2574
+ if (enc4.state !== "closed") enc4.close();
2340
2575
  }
2341
2576
  };
2342
2577
  },
2343
2578
  createDecoder(onPcm) {
2344
2579
  let ts = 0;
2345
- const dec3 = new AudioDecoder({
2580
+ const dec4 = new AudioDecoder({
2346
2581
  output: (audio) => {
2347
2582
  const pcm = new Float32Array(audio.numberOfFrames);
2348
2583
  audio.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
@@ -2351,18 +2586,18 @@ var OneLobby = (() => {
2351
2586
  },
2352
2587
  error: (e) => console.error("onelobby: AudioDecoder", e)
2353
2588
  });
2354
- dec3.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
2589
+ dec4.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
2355
2590
  return {
2356
2591
  decode(opus) {
2357
2592
  if (opus === null) {
2358
2593
  onPcm(new Float32Array(FRAME_SAMPLES));
2359
2594
  return;
2360
2595
  }
2361
- dec3.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
2596
+ dec4.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
2362
2597
  ts += 2e4;
2363
2598
  },
2364
2599
  close() {
2365
- if (dec3.state !== "closed") dec3.close();
2600
+ if (dec4.state !== "closed") dec4.close();
2366
2601
  }
2367
2602
  };
2368
2603
  }
@@ -2597,6 +2832,29 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2597
2832
  }
2598
2833
  return crc;
2599
2834
  }
2835
+ function parseIPv4(ip) {
2836
+ const parts = ip.split(".");
2837
+ if (parts.length !== 4) return null;
2838
+ let n = 0;
2839
+ for (const p of parts) {
2840
+ if (!/^\d{1,3}$/.test(p) || Number(p) > 255) return null;
2841
+ n = n * 256 + Number(p);
2842
+ }
2843
+ return n;
2844
+ }
2845
+ function encodeJoinCode(ipv4, port) {
2846
+ const ip = parseIPv4(ipv4);
2847
+ if (ip === null) throw new Error(`not an IPv4 address: ${ipv4}`);
2848
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`bad port ${port}`);
2849
+ const data = BigInt(ip) << 16n | BigInt(port);
2850
+ let v = data << 5n | BigInt(crc5(data, 50));
2851
+ let s = "";
2852
+ for (let i = 0; i < JOIN_CODE_LENGTH; i++) {
2853
+ s = CROCKFORD_ALPHABET[Number(v & 31n)] + s;
2854
+ v >>= 5n;
2855
+ }
2856
+ return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
2857
+ }
2600
2858
  function normalizeJoinCode(input) {
2601
2859
  const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
2602
2860
  if (s.length !== JOIN_CODE_LENGTH) return null;
@@ -2672,7 +2930,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2672
2930
 
2673
2931
  // src/onelobby.ts
2674
2932
  var LIB_NAME = "onelobby";
2675
- var LIB_VERSION = "0.1.0";
2933
+ var LIB_VERSION = "0.2.1";
2676
2934
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2677
2935
  function hasMic() {
2678
2936
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -2712,7 +2970,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2712
2970
  hashEveryTicks: o.hashEveryTicks ?? tickRate,
2713
2971
  displayName: (o.displayName ?? "").trim(),
2714
2972
  relayOnly: o.relayOnly ?? false,
2715
- readyTimeoutMs: o.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
2973
+ readyTimeoutMs: o.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
2974
+ peerTimeoutMs: o.peerTimeoutMs ?? 0
2716
2975
  };
2717
2976
  const a = o.advanced ?? {};
2718
2977
  const pick = (k) => a[k] !== void 0 ? a[k] : o[k];
@@ -2821,7 +3080,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2821
3080
  throw e;
2822
3081
  }
2823
3082
  }
2824
- makeLobby(sig, welcome, state, direct) {
3083
+ makeLobby(sig, welcome, state, direct, rateLimit) {
2825
3084
  const lobby = new Lobby({
2826
3085
  signaling: sig,
2827
3086
  state,
@@ -2834,7 +3093,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2834
3093
  relayOnly: state.relayOnly ?? this.options.relayOnly,
2835
3094
  displayName: this.options.displayName,
2836
3095
  voice: { codec: this.voiceCodec, createSource: this.createMic, createSink: this.createSink },
2837
- readyTimeoutMs: this.options.readyTimeoutMs
3096
+ readyTimeoutMs: this.options.readyTimeoutMs,
3097
+ peerTimeoutMs: this.options.peerTimeoutMs,
3098
+ rateLimit
2838
3099
  });
2839
3100
  lobby.errorSink = (e) => this.emit("error", e);
2840
3101
  if (this.storage && !direct) {
@@ -2865,7 +3126,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2865
3126
  },
2866
3127
  "lobby"
2867
3128
  );
2868
- return this.makeLobby(sig, welcome, state);
3129
+ return this.makeLobby(sig, welcome, state, void 0, h.rateLimit);
2869
3130
  } catch (e) {
2870
3131
  sig.close();
2871
3132
  throw e;
@@ -2913,7 +3174,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2913
3174
  const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
2914
3175
  try {
2915
3176
  const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
2916
- return this.makeLobby(sig, welcome, state, { links });
3177
+ const lobby = this.makeLobby(sig, welcome, state, { links });
3178
+ lobby.joinCode = joinedWith(addr);
3179
+ return lobby;
2917
3180
  } catch (e) {
2918
3181
  sig.close();
2919
3182
  throw e;
@@ -2936,7 +3199,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2936
3199
  },
2937
3200
  "lobby"
2938
3201
  );
2939
- return this.makeLobby(sig, welcome, state, { links: hooks.wrapLinks(this.linkFactory) });
3202
+ return this.makeLobby(sig, welcome, state, { links: hooks.wrapLinks(this.linkFactory) }, h.rateLimit);
2940
3203
  } catch (e) {
2941
3204
  sig.close();
2942
3205
  throw e;
@@ -2972,6 +3235,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2972
3235
  }
2973
3236
  }
2974
3237
  };
3238
+ function joinedWith(addr) {
3239
+ if (addr.isIPv6) return addr.display;
3240
+ try {
3241
+ return encodeJoinCode(addr.host, addr.port);
3242
+ } catch {
3243
+ return addr.display;
3244
+ }
3245
+ }
2975
3246
 
2976
3247
  // src/friendly.ts
2977
3248
  var TEXT = {
@@ -3069,9 +3340,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3069
3340
  var KeepaliveError = OneLobbyError;
3070
3341
 
3071
3342
  // src/ui/controller.ts
3072
- var enc2 = new TextEncoder();
3073
- var dec2 = new TextDecoder();
3074
- var toBytes = (w) => enc2.encode(JSON.stringify(w));
3343
+ var enc3 = new TextEncoder();
3344
+ var dec3 = new TextDecoder();
3345
+ var toBytes = (w) => enc3.encode(JSON.stringify(w));
3075
3346
  var SharedState = class {
3076
3347
  constructor(c) {
3077
3348
  this.c = c;
@@ -3312,7 +3583,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3312
3583
  onRaw(from, bytes) {
3313
3584
  let w;
3314
3585
  try {
3315
- w = JSON.parse(dec2.decode(bytes));
3586
+ w = JSON.parse(dec3.decode(bytes));
3316
3587
  } catch {
3317
3588
  return;
3318
3589
  }