onelobby 0.2.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.
@@ -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.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;
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.2.0 | MIT license */
1
+ /*! onelobby 0.2.1 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -1626,11 +1626,12 @@ var OneLobby = (() => {
1626
1626
 
1627
1627
  // src/socket.ts
1628
1628
  function closeCodeFor(reason) {
1629
- if (reason === "left") return 1e3;
1629
+ if (reason === "left" || reason === "disconnected") return 1e3;
1630
1630
  if (reason === "host_left" || reason === "closed") return 1001;
1631
1631
  if (reason === "kicked") return 4e3;
1632
1632
  return 1006;
1633
1633
  }
1634
+ var nextTask = globalThis.setImmediate ?? ((f) => void setTimeout(f, 0));
1634
1635
  var enc2 = new TextEncoder();
1635
1636
  var dec2 = new TextDecoder();
1636
1637
  var PeerSocket = class {
@@ -1646,7 +1647,9 @@ var OneLobby = (() => {
1646
1647
  onLocalClose;
1647
1648
  readyState = 0;
1648
1649
  bufferedAmount = 0;
1649
- onopen = null;
1650
+ openHandler = null;
1651
+ /** OPEN, but `onopen` has not run yet for any handler. */
1652
+ openPending = false;
1650
1653
  onmessage = null;
1651
1654
  onclose = null;
1652
1655
  onerror = null;
@@ -1662,12 +1665,28 @@ var OneLobby = (() => {
1662
1665
  this.closed(code, reason);
1663
1666
  this.onLocalClose();
1664
1667
  }
1665
- /** @internal OPEN now; `onopen` now, or one microtask later so a handler assigned right after creation still runs. */
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
+ */
1666
1679
  open(later = false) {
1667
1680
  if (this.readyState !== 0) return;
1668
1681
  this.readyState = 1;
1669
- if (!later) this.onopen?.({ type: "open" });
1670
- else queueMicrotask(() => this.readyState === 1 && this.onopen?.({ type: "open" }));
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" });
1671
1690
  }
1672
1691
  /** @internal */
1673
1692
  deliver(bytes) {
@@ -1689,7 +1708,7 @@ var OneLobby = (() => {
1689
1708
  lobby.on("message", (from, bytes) => {
1690
1709
  if (from === lobby.hostPeerId) s.deliver(bytes);
1691
1710
  }),
1692
- lobby.on("sessionEnded", (reason) => s.closed(closeCodeFor(reason), reason)),
1711
+ lobby.on("sessionEnded", (reason, close) => s.closed(close?.code ?? closeCodeFor(reason), close?.reason ?? reason)),
1693
1712
  // The socket's far end is the host it opened to; after a migration that server is gone (direct lobbies end instead).
1694
1713
  lobby.on("hostChanged", () => s.closed(1001, "host_changed"))
1695
1714
  ];
@@ -1722,7 +1741,10 @@ var OneLobby = (() => {
1722
1741
  const offs = [
1723
1742
  lobby.on("peerReady", (p) => void ensure(p.peerId)),
1724
1743
  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)),
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
+ ),
1726
1748
  lobby.on("sessionEnded", (reason) => {
1727
1749
  for (const c of [...conns.values()]) c.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason);
1728
1750
  offs.forEach((f) => f());
@@ -1736,6 +1758,7 @@ var OneLobby = (() => {
1736
1758
 
1737
1759
  // src/lobby.ts
1738
1760
  var MIN_PEER_TIMEOUT_MS = 6e3;
1761
+ var KICK_LINGER_MS = 3e3;
1739
1762
  function seatName(p) {
1740
1763
  const n = typeof p.name === "string" ? p.name.trim() : "";
1741
1764
  if (n && n !== "Player") return p;
@@ -1797,12 +1820,14 @@ var OneLobby = (() => {
1797
1820
  relayRetry = false;
1798
1821
  /** clock.now() of the last frame from each peer (peerTimeoutMs). */
1799
1822
  lastHeard = /* @__PURE__ */ new Map();
1823
+ /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
1824
+ lingering = /* @__PURE__ */ new Set();
1800
1825
  /** Host: per-peer fixed window for rateLimit. */
1801
1826
  rate = /* @__PURE__ */ new Map();
1802
1827
  /**
1803
1828
  * Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
1804
1829
  * 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).
1830
+ * (or with "closed"/"kicked" when the lobby ends first; "closed" also when the host disconnected it).
1806
1831
  */
1807
1832
  ready = new Promise((res, rej) => {
1808
1833
  this.markReady = () => {
@@ -1930,10 +1955,32 @@ var OneLobby = (() => {
1930
1955
  this.signaling.send({ t: "meta", meta });
1931
1956
  this.emit("metaChanged", this.meta);
1932
1957
  }
1933
- 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 = {}) {
1934
1964
  if (!this.isHost) throw new Error("only the host can kick");
1935
- this.signaling.send({ t: "kick", peerId });
1936
- 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 });
1937
1984
  }
1938
1985
  async approve(peerId, ok) {
1939
1986
  if (!this.isHost) throw new Error("only the host can approve joins");
@@ -2334,8 +2381,13 @@ var OneLobby = (() => {
2334
2381
  void this.onSignal(Number(m.from), m);
2335
2382
  break;
2336
2383
  case "error":
2337
- if (m.code === "kicked") this.teardown("kicked");
2338
- 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));
2339
2391
  break;
2340
2392
  }
2341
2393
  }
@@ -2350,7 +2402,12 @@ var OneLobby = (() => {
2350
2402
  if (s.ice) payload.ice = s.ice;
2351
2403
  await link.handleSignal(payload);
2352
2404
  }
2353
- 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) {
2354
2411
  const peer = this.roster.get(peerId);
2355
2412
  if (peerId === this.localPeerId) {
2356
2413
  this.teardown(reason === "kicked" ? "kicked" : reason);
@@ -2359,11 +2416,23 @@ var OneLobby = (() => {
2359
2416
  if (peer) this.roster.delete(peerId);
2360
2417
  const link = this.links.get(peerId);
2361
2418
  if (link && peerId !== this.hostPeerId) {
2362
- link.onClose = null;
2363
2419
  link.onMessage = null;
2364
- link.close();
2365
2420
  this.links.delete(peerId);
2366
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
+ }
2367
2436
  }
2368
2437
  this.connected.delete(peerId);
2369
2438
  this.outbox.delete(peerId);
@@ -2372,14 +2441,15 @@ var OneLobby = (() => {
2372
2441
  this.lockstep.onPeerLeft(peerId);
2373
2442
  this.chat.onPeerLeft(peerId);
2374
2443
  this.voice.onPeerLeft(peerId);
2375
- if (peer) this.emit("peerLeft", peer, reason);
2444
+ if (peer) this.emit("peerLeft", peer, reason, close);
2376
2445
  if (peerId === this.hostPeerId && !this.isHost) {
2377
2446
  this.handleHostLost(reason);
2378
2447
  }
2379
2448
  }
2449
+ /** Lockstep drops (stalled, desynced): removed without a ban, so the player can come back (a rejoin resyncs). */
2380
2450
  dropPeer(peerId, reason) {
2381
- this.signaling.send({ t: "kick", peerId });
2382
- this.removePeer(peerId, reason);
2451
+ this.signaling.send({ t: "kick", peerId, ban: false, closeReason: reason });
2452
+ this.removePeer(peerId, reason, void 0, true);
2383
2453
  }
2384
2454
  setPhase(p) {
2385
2455
  if (p !== "lobby" && p !== "in_game") return;
@@ -2430,7 +2500,7 @@ var OneLobby = (() => {
2430
2500
  if (this.ended || this.isHost) return;
2431
2501
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
2432
2502
  }
2433
- teardown(reason) {
2503
+ teardown(reason, close) {
2434
2504
  if (this.ended) return;
2435
2505
  this.ended = true;
2436
2506
  this.cancelReadyTimer?.();
@@ -2442,16 +2512,17 @@ var OneLobby = (() => {
2442
2512
  this.cancelPing?.();
2443
2513
  this.lockstep.stop();
2444
2514
  this.voice.close();
2445
- for (const l of this.links.values()) {
2515
+ for (const l of [...this.links.values(), ...this.lingering]) {
2446
2516
  l.onClose = null;
2447
2517
  l.onMessage = null;
2448
2518
  l.close();
2449
2519
  }
2450
2520
  this.links.clear();
2521
+ this.lingering.clear();
2451
2522
  this.signaling.onClose = null;
2452
2523
  this.signaling.onMessage = null;
2453
2524
  this.signaling.close();
2454
- this.emit("sessionEnded", reason);
2525
+ this.emit("sessionEnded", reason, close);
2455
2526
  }
2456
2527
  /** Total in-flight links (diagnostics and tests). */
2457
2528
  get linkCount() {
@@ -2761,6 +2832,29 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2761
2832
  }
2762
2833
  return crc;
2763
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
+ }
2764
2858
  function normalizeJoinCode(input) {
2765
2859
  const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
2766
2860
  if (s.length !== JOIN_CODE_LENGTH) return null;
@@ -2836,7 +2930,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2836
2930
 
2837
2931
  // src/onelobby.ts
2838
2932
  var LIB_NAME = "onelobby";
2839
- var LIB_VERSION = "0.2.0";
2933
+ var LIB_VERSION = "0.2.1";
2840
2934
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2841
2935
  function hasMic() {
2842
2936
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -3080,7 +3174,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3080
3174
  const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3081
3175
  try {
3082
3176
  const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3083
- return this.makeLobby(sig, welcome, state, { links });
3177
+ const lobby = this.makeLobby(sig, welcome, state, { links });
3178
+ lobby.joinCode = joinedWith(addr);
3179
+ return lobby;
3084
3180
  } catch (e) {
3085
3181
  sig.close();
3086
3182
  throw e;
@@ -3139,6 +3235,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3139
3235
  }
3140
3236
  }
3141
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
+ }
3142
3246
 
3143
3247
  // src/friendly.ts
3144
3248
  var TEXT = {