onelobby 0.2.0 → 0.2.2

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.
@@ -5,7 +5,7 @@ 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.2.0";
8
+ export declare const LIB_VERSION = "0.2.2";
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;
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.2.0 | MIT license */
1
+ /*! onelobby 0.2.2 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -1146,8 +1146,19 @@ var OneLobby = (() => {
1146
1146
  cancelPing = null;
1147
1147
  pingN = 0;
1148
1148
  closed = false;
1149
- onMessage = null;
1149
+ handler = null;
1150
+ // Messages with no waiter and no handler yet. The reply that resolves join/create can arrive in the same socket
1151
+ // read as the host's first offer; both are dispatched before the awaiting code builds its Lobby and sets a handler.
1152
+ backlog = [];
1150
1153
  onClose = null;
1154
+ get onMessage() {
1155
+ return this.handler;
1156
+ }
1157
+ /** Setting a handler first delivers, in order, what arrived while there was none. */
1158
+ set onMessage(h) {
1159
+ this.handler = h;
1160
+ while (h && this.handler === h && this.backlog.length) h(this.backlog.shift());
1161
+ }
1151
1162
  connect() {
1152
1163
  return new Promise((resolve, reject) => {
1153
1164
  let s;
@@ -1187,7 +1198,8 @@ var OneLobby = (() => {
1187
1198
  else w.resolve(m);
1188
1199
  return;
1189
1200
  }
1190
- this.onMessage?.(m);
1201
+ if (this.handler) this.handler(m);
1202
+ else if (this.backlog.length < 256) this.backlog.push(m);
1191
1203
  };
1192
1204
  s.onclose = () => {
1193
1205
  if (!this.lastRx) reject(new OneLobbyError("connect_failed", `cannot reach ${this.url}`));
@@ -1626,11 +1638,12 @@ var OneLobby = (() => {
1626
1638
 
1627
1639
  // src/socket.ts
1628
1640
  function closeCodeFor(reason) {
1629
- if (reason === "left") return 1e3;
1641
+ if (reason === "left" || reason === "disconnected") return 1e3;
1630
1642
  if (reason === "host_left" || reason === "closed") return 1001;
1631
1643
  if (reason === "kicked") return 4e3;
1632
1644
  return 1006;
1633
1645
  }
1646
+ var nextTask = globalThis.setImmediate ?? ((f) => void setTimeout(f, 0));
1634
1647
  var enc2 = new TextEncoder();
1635
1648
  var dec2 = new TextDecoder();
1636
1649
  var PeerSocket = class {
@@ -1646,7 +1659,9 @@ var OneLobby = (() => {
1646
1659
  onLocalClose;
1647
1660
  readyState = 0;
1648
1661
  bufferedAmount = 0;
1649
- onopen = null;
1662
+ openHandler = null;
1663
+ /** OPEN, but `onopen` has not run yet for any handler. */
1664
+ openPending = false;
1650
1665
  onmessage = null;
1651
1666
  onclose = null;
1652
1667
  onerror = null;
@@ -1662,12 +1677,28 @@ var OneLobby = (() => {
1662
1677
  this.closed(code, reason);
1663
1678
  this.onLocalClose();
1664
1679
  }
1665
- /** @internal OPEN now; `onopen` now, or one microtask later so a handler assigned right after creation still runs. */
1680
+ get onopen() {
1681
+ return this.openHandler;
1682
+ }
1683
+ set onopen(h) {
1684
+ this.openHandler = h;
1685
+ if (h && this.openPending) nextTask(() => this.fireOpen());
1686
+ }
1687
+ /**
1688
+ * @internal OPEN now; `onopen` now, or on a later task (like a real WebSocket) so a handler assigned
1689
+ * after any number of awaits in the same task still runs.
1690
+ */
1666
1691
  open(later = false) {
1667
1692
  if (this.readyState !== 0) return;
1668
1693
  this.readyState = 1;
1669
- if (!later) this.onopen?.({ type: "open" });
1670
- else queueMicrotask(() => this.readyState === 1 && this.onopen?.({ type: "open" }));
1694
+ this.openPending = true;
1695
+ if (!later) this.fireOpen();
1696
+ else nextTask(() => this.fireOpen());
1697
+ }
1698
+ fireOpen() {
1699
+ if (this.readyState !== 1 || !this.openPending || !this.openHandler) return;
1700
+ this.openPending = false;
1701
+ this.openHandler({ type: "open" });
1671
1702
  }
1672
1703
  /** @internal */
1673
1704
  deliver(bytes) {
@@ -1689,7 +1720,7 @@ var OneLobby = (() => {
1689
1720
  lobby.on("message", (from, bytes) => {
1690
1721
  if (from === lobby.hostPeerId) s.deliver(bytes);
1691
1722
  }),
1692
- lobby.on("sessionEnded", (reason) => s.closed(closeCodeFor(reason), reason)),
1723
+ lobby.on("sessionEnded", (reason, close) => s.closed(close?.code ?? closeCodeFor(reason), close?.reason ?? reason)),
1693
1724
  // The socket's far end is the host it opened to; after a migration that server is gone (direct lobbies end instead).
1694
1725
  lobby.on("hostChanged", () => s.closed(1001, "host_changed"))
1695
1726
  ];
@@ -1722,7 +1753,10 @@ var OneLobby = (() => {
1722
1753
  const offs = [
1723
1754
  lobby.on("peerReady", (p) => void ensure(p.peerId)),
1724
1755
  lobby.on("message", (from, bytes) => ensure(from)?.deliver(bytes)),
1725
- lobby.on("peerLeft", (p, reason) => conns.get(p.peerId)?.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason)),
1756
+ lobby.on(
1757
+ "peerLeft",
1758
+ (p, reason, close) => conns.get(p.peerId)?.closed(close?.code ?? (reason === "left" ? 1001 : closeCodeFor(reason)), close?.reason ?? reason)
1759
+ ),
1726
1760
  lobby.on("sessionEnded", (reason) => {
1727
1761
  for (const c of [...conns.values()]) c.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason);
1728
1762
  offs.forEach((f) => f());
@@ -1736,6 +1770,7 @@ var OneLobby = (() => {
1736
1770
 
1737
1771
  // src/lobby.ts
1738
1772
  var MIN_PEER_TIMEOUT_MS = 6e3;
1773
+ var KICK_LINGER_MS = 3e3;
1739
1774
  function seatName(p) {
1740
1775
  const n = typeof p.name === "string" ? p.name.trim() : "";
1741
1776
  if (n && n !== "Player") return p;
@@ -1775,6 +1810,8 @@ var OneLobby = (() => {
1775
1810
  cfg;
1776
1811
  roster = /* @__PURE__ */ new Map();
1777
1812
  links = /* @__PURE__ */ new Map();
1813
+ /** ICE from a peer that has no link yet: its candidates can overtake its offer (node-datachannel). */
1814
+ earlyIce = /* @__PURE__ */ new Map();
1778
1815
  connected = /* @__PURE__ */ new Set();
1779
1816
  // host: peers that said hi
1780
1817
  hiSentTo = /* @__PURE__ */ new Set();
@@ -1797,12 +1834,14 @@ var OneLobby = (() => {
1797
1834
  relayRetry = false;
1798
1835
  /** clock.now() of the last frame from each peer (peerTimeoutMs). */
1799
1836
  lastHeard = /* @__PURE__ */ new Map();
1837
+ /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
1838
+ lingering = /* @__PURE__ */ new Set();
1800
1839
  /** Host: per-peer fixed window for rateLimit. */
1801
1840
  rate = /* @__PURE__ */ new Map();
1802
1841
  /**
1803
1842
  * Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
1804
1843
  * joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
1805
- * (or with "closed"/"kicked" when the lobby ends first).
1844
+ * (or with "closed"/"kicked" when the lobby ends first; "closed" also when the host disconnected it).
1806
1845
  */
1807
1846
  ready = new Promise((res, rej) => {
1808
1847
  this.markReady = () => {
@@ -1930,10 +1969,32 @@ var OneLobby = (() => {
1930
1969
  this.signaling.send({ t: "meta", meta });
1931
1970
  this.emit("metaChanged", this.meta);
1932
1971
  }
1933
- async kick(peerId) {
1972
+ /**
1973
+ * Host: removes a player. By default it is a ban: the hub refuses that player's address for the rest of this lobby
1974
+ * (a new tab or a reload included). `{ ban: false }` is `disconnect()`. `code` / `reason` reach the player's
1975
+ * `asWebSocket()` onclose (and `sessionEnded`'s second argument); without them its socket closes with 4000 "kicked".
1976
+ */
1977
+ async kick(peerId, opts = {}) {
1934
1978
  if (!this.isHost) throw new Error("only the host can kick");
1935
- this.signaling.send({ t: "kick", peerId });
1936
- this.removePeer(peerId, "kicked");
1979
+ const { ban = true, code, reason } = opts;
1980
+ if (code !== void 0 && !(code === 1e3 || Number.isInteger(code) && code >= 3e3 && code <= 4999))
1981
+ throw new RangeError("close code must be 1000 or 3000-4999");
1982
+ if (reason !== void 0 && reason.length > 120) throw new RangeError("close reason must be at most 120 characters");
1983
+ const msg = { t: "kick", peerId };
1984
+ if (!ban) msg.ban = false;
1985
+ if (code !== void 0) msg.closeCode = code;
1986
+ if (reason !== void 0) msg.closeReason = reason;
1987
+ this.signaling.send(msg);
1988
+ const close = code !== void 0 || reason !== void 0 ? { code, reason } : void 0;
1989
+ this.removePeer(peerId, ban ? "kicked" : "disconnected", close, true);
1990
+ }
1991
+ /**
1992
+ * Host: ends one player's connection without a ban, like a server closing one WebSocket. The player's
1993
+ * `asWebSocket()` gets onclose with `code` (default 1000) and `reason` (default "disconnected"), its lobby ends with
1994
+ * `sessionEnded("disconnected")`, and it may join again (a new tab, a reload). Same as `kick(peerId, { ban: false, code, reason })`.
1995
+ */
1996
+ disconnect(peerId, code, reason) {
1997
+ return this.kick(peerId, { ban: false, code, reason });
1937
1998
  }
1938
1999
  async approve(peerId, ok) {
1939
2000
  if (!this.isHost) throw new Error("only the host can approve joins");
@@ -2334,36 +2395,71 @@ var OneLobby = (() => {
2334
2395
  void this.onSignal(Number(m.from), m);
2335
2396
  break;
2336
2397
  case "error":
2337
- if (m.code === "kicked") this.teardown("kicked");
2338
- else this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
2398
+ if (m.code === "kicked") {
2399
+ const d = m.data ?? {};
2400
+ const close = {};
2401
+ if (typeof d.closeCode === "number") close.code = d.closeCode;
2402
+ if (typeof d.closeReason === "string") close.reason = d.closeReason;
2403
+ this.teardown(d.ban === false ? "disconnected" : "kicked", close.code !== void 0 || close.reason !== void 0 ? close : void 0);
2404
+ } else this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
2339
2405
  break;
2340
2406
  }
2341
2407
  }
2342
2408
  async onSignal(from, s) {
2343
2409
  let link = this.links.get(from);
2344
- if (s.sdp?.type === "offer") {
2345
- link = this.makeLink(from);
2346
- }
2347
- if (!link) return;
2348
2410
  const payload = {};
2349
2411
  if (s.sdp) payload.sdp = s.sdp;
2350
2412
  if (s.ice) payload.ice = s.ice;
2413
+ if (s.sdp?.type === "offer") {
2414
+ link = this.makeLink(from);
2415
+ const early = this.earlyIce.get(from) ?? [];
2416
+ this.earlyIce.delete(from);
2417
+ const done = link.handleSignal(payload);
2418
+ for (const e of early) void link.handleSignal(e);
2419
+ await done;
2420
+ return;
2421
+ }
2422
+ if (!link) {
2423
+ if (!payload.ice || this.ended || !this.roster.has(from)) return;
2424
+ const q = this.earlyIce.get(from) ?? [];
2425
+ if (q.length < 64) q.push(payload);
2426
+ this.earlyIce.set(from, q);
2427
+ return;
2428
+ }
2351
2429
  await link.handleSignal(payload);
2352
2430
  }
2353
- removePeer(peerId, reason) {
2431
+ /**
2432
+ * `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
2433
+ * or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
2434
+ * like the host vanished (a migration and a 1001 instead of the host's close code).
2435
+ */
2436
+ removePeer(peerId, reason, close, linger = false) {
2354
2437
  const peer = this.roster.get(peerId);
2355
2438
  if (peerId === this.localPeerId) {
2356
2439
  this.teardown(reason === "kicked" ? "kicked" : reason);
2357
2440
  return;
2358
2441
  }
2359
2442
  if (peer) this.roster.delete(peerId);
2443
+ this.earlyIce.delete(peerId);
2360
2444
  const link = this.links.get(peerId);
2361
2445
  if (link && peerId !== this.hostPeerId) {
2362
- link.onClose = null;
2363
2446
  link.onMessage = null;
2364
- link.close();
2365
2447
  this.links.delete(peerId);
2366
2448
  this.hiSentTo.delete(link);
2449
+ if (linger) {
2450
+ this.lingering.add(link);
2451
+ const done = () => {
2452
+ if (!this.lingering.delete(link)) return;
2453
+ cancel();
2454
+ link.onClose = null;
2455
+ link.close();
2456
+ };
2457
+ const cancel = this.cfg.clock.setTimeout(done, KICK_LINGER_MS);
2458
+ link.onClose = done;
2459
+ } else {
2460
+ link.onClose = null;
2461
+ link.close();
2462
+ }
2367
2463
  }
2368
2464
  this.connected.delete(peerId);
2369
2465
  this.outbox.delete(peerId);
@@ -2372,14 +2468,15 @@ var OneLobby = (() => {
2372
2468
  this.lockstep.onPeerLeft(peerId);
2373
2469
  this.chat.onPeerLeft(peerId);
2374
2470
  this.voice.onPeerLeft(peerId);
2375
- if (peer) this.emit("peerLeft", peer, reason);
2471
+ if (peer) this.emit("peerLeft", peer, reason, close);
2376
2472
  if (peerId === this.hostPeerId && !this.isHost) {
2377
2473
  this.handleHostLost(reason);
2378
2474
  }
2379
2475
  }
2476
+ /** Lockstep drops (stalled, desynced): removed without a ban, so the player can come back (a rejoin resyncs). */
2380
2477
  dropPeer(peerId, reason) {
2381
- this.signaling.send({ t: "kick", peerId });
2382
- this.removePeer(peerId, reason);
2478
+ this.signaling.send({ t: "kick", peerId, ban: false, closeReason: reason });
2479
+ this.removePeer(peerId, reason, void 0, true);
2383
2480
  }
2384
2481
  setPhase(p) {
2385
2482
  if (p !== "lobby" && p !== "in_game") return;
@@ -2430,7 +2527,7 @@ var OneLobby = (() => {
2430
2527
  if (this.ended || this.isHost) return;
2431
2528
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
2432
2529
  }
2433
- teardown(reason) {
2530
+ teardown(reason, close) {
2434
2531
  if (this.ended) return;
2435
2532
  this.ended = true;
2436
2533
  this.cancelReadyTimer?.();
@@ -2442,16 +2539,18 @@ var OneLobby = (() => {
2442
2539
  this.cancelPing?.();
2443
2540
  this.lockstep.stop();
2444
2541
  this.voice.close();
2445
- for (const l of this.links.values()) {
2542
+ for (const l of [...this.links.values(), ...this.lingering]) {
2446
2543
  l.onClose = null;
2447
2544
  l.onMessage = null;
2448
2545
  l.close();
2449
2546
  }
2450
2547
  this.links.clear();
2548
+ this.earlyIce.clear();
2549
+ this.lingering.clear();
2451
2550
  this.signaling.onClose = null;
2452
2551
  this.signaling.onMessage = null;
2453
2552
  this.signaling.close();
2454
- this.emit("sessionEnded", reason);
2553
+ this.emit("sessionEnded", reason, close);
2455
2554
  }
2456
2555
  /** Total in-flight links (diagnostics and tests). */
2457
2556
  get linkCount() {
@@ -2620,8 +2719,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2620
2719
  pc;
2621
2720
  channels = {};
2622
2721
  pendingIce = [];
2722
+ /**
2723
+ * Set once setRemoteDescription resolves. Not `pc.remoteDescription`: node-datachannel's polyfill returns an object
2724
+ * there before any is set, and its addIceCandidate throws until one is.
2725
+ */
2726
+ haveRemote = false;
2623
2727
  closed = false;
2624
2728
  opened = 0;
2729
+ early = [];
2625
2730
  extra;
2626
2731
  /** `rtc`: the RTCPeerConnection class to use (default the global one; in Node, node-datachannel's polyfill). */
2627
2732
  constructor(opts, rtc = globalThis.RTCPeerConnection) {
@@ -2647,6 +2752,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2647
2752
  else this.fail(s);
2648
2753
  }
2649
2754
  };
2755
+ }
2756
+ /**
2757
+ * The offerer creates the channels just before its offer, the answerer once the remote offer is set. Creating them
2758
+ * any earlier makes libdatachannel (node-datachannel) start an offer of its own on the answering side, which then
2759
+ * collides with the real one (both ICE agents controlling, the link never opens).
2760
+ */
2761
+ openChannels() {
2762
+ if (this.channels.reliable) return;
2650
2763
  for (const name of ["reliable", "fast", "voice"]) {
2651
2764
  const ch = this.pc.createDataChannel(name, CHANNEL_INIT[name]);
2652
2765
  ch.binaryType = "arraybuffer";
@@ -2654,6 +2767,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2654
2767
  if (++this.opened === 3 && !this.closed) {
2655
2768
  this.isOpen = true;
2656
2769
  this.onOpen?.();
2770
+ for (const [n, data] of this.early.splice(0)) if (!this.closed) this.onMessage?.(n, data);
2657
2771
  void this.reportPath();
2658
2772
  }
2659
2773
  };
@@ -2661,6 +2775,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2661
2775
  ch.onmessage = (e) => {
2662
2776
  const d = e.data;
2663
2777
  const data = d instanceof ArrayBuffer ? new Uint8Array(d) : ArrayBuffer.isView(d) ? new Uint8Array(d.buffer, d.byteOffset, d.byteLength) : new Uint8Array(d);
2778
+ if (!this.isOpen) {
2779
+ if (this.early.length < 1e3) this.early.push([name, data]);
2780
+ return;
2781
+ }
2664
2782
  this.onMessage?.(name, data);
2665
2783
  };
2666
2784
  this.channels[name] = ch;
@@ -2669,6 +2787,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2669
2787
  startOffer() {
2670
2788
  void (async () => {
2671
2789
  try {
2790
+ this.openChannels();
2672
2791
  const offer = await this.pc.createOffer();
2673
2792
  await this.pc.setLocalDescription(offer);
2674
2793
  this.onSignal?.({ sdp: { type: "offer", sdp: offer.sdp ?? "" } });
@@ -2681,19 +2800,21 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2681
2800
  try {
2682
2801
  if (s.sdp) {
2683
2802
  await this.pc.setRemoteDescription({ type: s.sdp.type, sdp: s.sdp.sdp });
2803
+ this.haveRemote = true;
2684
2804
  for (const c of this.pendingIce.splice(0)) await this.pc.addIceCandidate(c);
2685
2805
  if (this.extra.length) {
2686
2806
  const mid = /^a=mid:(\S+)/m.exec(s.sdp.sdp)?.[1] ?? "0";
2687
2807
  for (const candidate of this.extra.splice(0)) await this.pc.addIceCandidate({ candidate, sdpMid: mid, sdpMLineIndex: 0 });
2688
2808
  }
2689
2809
  if (s.sdp.type === "offer") {
2810
+ this.openChannels();
2690
2811
  const answer = await this.pc.createAnswer();
2691
2812
  await this.pc.setLocalDescription(answer);
2692
2813
  this.onSignal?.({ sdp: { type: "answer", sdp: answer.sdp ?? "" } });
2693
2814
  }
2694
2815
  }
2695
2816
  if (s.ice) {
2696
- if (this.pc.remoteDescription) await this.pc.addIceCandidate(s.ice);
2817
+ if (this.haveRemote) await this.pc.addIceCandidate(s.ice);
2697
2818
  else this.pendingIce.push(s.ice);
2698
2819
  }
2699
2820
  } catch (e) {
@@ -2761,6 +2882,29 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2761
2882
  }
2762
2883
  return crc;
2763
2884
  }
2885
+ function parseIPv4(ip) {
2886
+ const parts = ip.split(".");
2887
+ if (parts.length !== 4) return null;
2888
+ let n = 0;
2889
+ for (const p of parts) {
2890
+ if (!/^\d{1,3}$/.test(p) || Number(p) > 255) return null;
2891
+ n = n * 256 + Number(p);
2892
+ }
2893
+ return n;
2894
+ }
2895
+ function encodeJoinCode(ipv4, port) {
2896
+ const ip = parseIPv4(ipv4);
2897
+ if (ip === null) throw new Error(`not an IPv4 address: ${ipv4}`);
2898
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`bad port ${port}`);
2899
+ const data = BigInt(ip) << 16n | BigInt(port);
2900
+ let v = data << 5n | BigInt(crc5(data, 50));
2901
+ let s = "";
2902
+ for (let i = 0; i < JOIN_CODE_LENGTH; i++) {
2903
+ s = CROCKFORD_ALPHABET[Number(v & 31n)] + s;
2904
+ v >>= 5n;
2905
+ }
2906
+ return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
2907
+ }
2764
2908
  function normalizeJoinCode(input) {
2765
2909
  const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
2766
2910
  if (s.length !== JOIN_CODE_LENGTH) return null;
@@ -2836,7 +2980,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2836
2980
 
2837
2981
  // src/onelobby.ts
2838
2982
  var LIB_NAME = "onelobby";
2839
- var LIB_VERSION = "0.2.0";
2983
+ var LIB_VERSION = "0.2.2";
2840
2984
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2841
2985
  function hasMic() {
2842
2986
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -3080,7 +3224,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3080
3224
  const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3081
3225
  try {
3082
3226
  const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3083
- return this.makeLobby(sig, welcome, state, { links });
3227
+ const lobby = this.makeLobby(sig, welcome, state, { links });
3228
+ lobby.joinCode = joinedWith(addr);
3229
+ return lobby;
3084
3230
  } catch (e) {
3085
3231
  sig.close();
3086
3232
  throw e;
@@ -3139,6 +3285,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3139
3285
  }
3140
3286
  }
3141
3287
  };
3288
+ function joinedWith(addr) {
3289
+ if (addr.isIPv6) return addr.display;
3290
+ try {
3291
+ return encodeJoinCode(addr.host, addr.port);
3292
+ } catch {
3293
+ return addr.display;
3294
+ }
3295
+ }
3142
3296
 
3143
3297
  // src/friendly.ts
3144
3298
  var TEXT = {