onelobby 0.1.0 → 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.
@@ -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.0";
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.0 | 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,118 @@ 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") return 1e3;
1630
+ if (reason === "host_left" || reason === "closed") return 1001;
1631
+ if (reason === "kicked") return 4e3;
1632
+ return 1006;
1633
+ }
1634
+ var enc2 = new TextEncoder();
1635
+ var dec2 = new TextDecoder();
1636
+ var PeerSocket = class {
1637
+ constructor(peerId, out, binary, onLocalClose) {
1638
+ this.peerId = peerId;
1639
+ this.out = out;
1640
+ this.binary = binary;
1641
+ this.onLocalClose = onLocalClose;
1642
+ }
1643
+ peerId;
1644
+ out;
1645
+ binary;
1646
+ onLocalClose;
1647
+ readyState = 0;
1648
+ bufferedAmount = 0;
1649
+ onopen = null;
1650
+ onmessage = null;
1651
+ onclose = null;
1652
+ onerror = null;
1653
+ /** Unsubscribes from the lobby; set by the factory. */
1654
+ detach = () => {
1655
+ };
1656
+ send(data) {
1657
+ if (this.readyState !== 1) return;
1658
+ this.out(typeof data === "string" ? enc2.encode(data) : data);
1659
+ }
1660
+ close(code = 1e3, reason = "left") {
1661
+ if (this.readyState === 3) return;
1662
+ this.closed(code, reason);
1663
+ this.onLocalClose();
1664
+ }
1665
+ /** @internal OPEN now; `onopen` now, or one microtask later so a handler assigned right after creation still runs. */
1666
+ open(later = false) {
1667
+ if (this.readyState !== 0) return;
1668
+ this.readyState = 1;
1669
+ if (!later) this.onopen?.({ type: "open" });
1670
+ else queueMicrotask(() => this.readyState === 1 && this.onopen?.({ type: "open" }));
1671
+ }
1672
+ /** @internal */
1673
+ deliver(bytes) {
1674
+ if (this.readyState === 1) this.onmessage?.({ type: "message", data: this.binary ? bytes : dec2.decode(bytes) });
1675
+ }
1676
+ /** @internal */
1677
+ closed(code, reason) {
1678
+ if (this.readyState === 3) return;
1679
+ this.readyState = 3;
1680
+ this.detach();
1681
+ this.onclose?.({ type: "close", code, reason, wasClean: code === 1e3 || code === 1001 });
1682
+ }
1683
+ };
1684
+ function joinerSocket(lobby, o = {}) {
1685
+ if (lobby.isHost) throw new Error("asWebSocket() is for joiners; the host uses lobby.onConnection()");
1686
+ const channel = o.channel ?? "reliable";
1687
+ const s = new PeerSocket(lobby.hostPeerId, (b) => lobby.send(lobby.hostPeerId, b, { channel }), !!o.binary, () => void lobby.leave());
1688
+ const offs = [
1689
+ lobby.on("message", (from, bytes) => {
1690
+ if (from === lobby.hostPeerId) s.deliver(bytes);
1691
+ }),
1692
+ lobby.on("sessionEnded", (reason) => s.closed(closeCodeFor(reason), reason)),
1693
+ // The socket's far end is the host it opened to; after a migration that server is gone (direct lobbies end instead).
1694
+ lobby.on("hostChanged", () => s.closed(1001, "host_changed"))
1695
+ ];
1696
+ s.detach = () => offs.forEach((f) => f());
1697
+ if (lobby.connectedPeers.includes(lobby.hostPeerId)) s.open(true);
1698
+ else lobby.ready.then(() => s.open(true), () => {
1699
+ });
1700
+ return s;
1701
+ }
1702
+ function hostConnections(lobby, handler, o = {}) {
1703
+ if (!lobby.isHost) throw new Error("onConnection() is for the host; a joiner uses lobby.asWebSocket()");
1704
+ const channel = o.channel ?? "reliable";
1705
+ const conns = /* @__PURE__ */ new Map();
1706
+ let stopped = false;
1707
+ const ensure = (peerId) => {
1708
+ if (peerId === lobby.localPeerId) return void 0;
1709
+ let c = conns.get(peerId);
1710
+ if (c || stopped) return c;
1711
+ c = new PeerSocket(peerId, (b) => lobby.send(peerId, b, { channel }), !!o.binary, () => {
1712
+ });
1713
+ const conn = c;
1714
+ conn.detach = () => {
1715
+ if (conns.get(peerId) === conn) conns.delete(peerId);
1716
+ };
1717
+ conns.set(peerId, conn);
1718
+ conn.open();
1719
+ handler(conn);
1720
+ return conn;
1721
+ };
1722
+ const offs = [
1723
+ lobby.on("peerReady", (p) => void ensure(p.peerId)),
1724
+ 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)),
1726
+ lobby.on("sessionEnded", (reason) => {
1727
+ for (const c of [...conns.values()]) c.closed(reason === "left" ? 1001 : closeCodeFor(reason), reason);
1728
+ offs.forEach((f) => f());
1729
+ })
1730
+ ];
1731
+ for (const id of lobby.connectedPeers) ensure(id);
1732
+ return () => {
1733
+ stopped = true;
1734
+ };
1735
+ }
1736
+
1625
1737
  // src/lobby.ts
1738
+ var MIN_PEER_TIMEOUT_MS = 6e3;
1626
1739
  function seatName(p) {
1627
1740
  const n = typeof p.name === "string" ? p.name.trim() : "";
1628
1741
  if (n && n !== "Player") return p;
@@ -1682,6 +1795,10 @@ var OneLobby = (() => {
1682
1795
  isReady = false;
1683
1796
  cancelReadyTimer = null;
1684
1797
  relayRetry = false;
1798
+ /** clock.now() of the last frame from each peer (peerTimeoutMs). */
1799
+ lastHeard = /* @__PURE__ */ new Map();
1800
+ /** Host: per-peer fixed window for rateLimit. */
1801
+ rate = /* @__PURE__ */ new Map();
1685
1802
  /**
1686
1803
  * Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
1687
1804
  * joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
@@ -1765,6 +1882,21 @@ var OneLobby = (() => {
1765
1882
  peer(peerId) {
1766
1883
  return this.roster.get(peerId);
1767
1884
  }
1885
+ /** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
1886
+ get connectedPeers() {
1887
+ return this.connectedPeerIds();
1888
+ }
1889
+ /**
1890
+ * Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
1891
+ * unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
1892
+ */
1893
+ asWebSocket(opts) {
1894
+ return joinerSocket(this, opts);
1895
+ }
1896
+ /** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
1897
+ onConnection(handler, opts) {
1898
+ return hostConnections(this, handler, opts);
1899
+ }
1768
1900
  set snapshotChunkSize(n) {
1769
1901
  this.lockstep.chunkSize = n;
1770
1902
  }
@@ -1972,6 +2104,7 @@ var OneLobby = (() => {
1972
2104
  return;
1973
2105
  }
1974
2106
  if (!d) return;
2107
+ if (!reassembled) this.lastHeard.set(from, this.cfg.clock.now());
1975
2108
  const { msg, route } = d;
1976
2109
  switch (msg.type) {
1977
2110
  case "hi":
@@ -2056,6 +2189,7 @@ var OneLobby = (() => {
2056
2189
  this.emit("message", route, bytes, channel);
2057
2190
  return;
2058
2191
  }
2192
+ if (!this.allowRaw(from)) return;
2059
2193
  if (route === 0 || route === this.localPeerId) {
2060
2194
  this.emit("message", from, bytes, channel);
2061
2195
  }
@@ -2065,6 +2199,14 @@ var OneLobby = (() => {
2065
2199
  this.sendTo(route, { type: "raw", bytes }, from, channel);
2066
2200
  }
2067
2201
  }
2202
+ allowRaw(from) {
2203
+ const lim = this.cfg.rateLimit;
2204
+ if (!lim || !(lim.messages > 0) || !(lim.perMs > 0)) return true;
2205
+ const now = this.cfg.clock.now();
2206
+ let w = this.rate.get(from);
2207
+ if (!w || now - w.start >= lim.perMs) this.rate.set(from, w = { start: now, n: 0 });
2208
+ return ++w.n <= lim.messages;
2209
+ }
2068
2210
  // ---- migration -------------------------------------------------------------------------------------------------
2069
2211
  setHost(hostId) {
2070
2212
  if (hostId === this.hostPeerId) return;
@@ -2101,6 +2243,7 @@ var OneLobby = (() => {
2101
2243
  this.markReady();
2102
2244
  this.connected.clear();
2103
2245
  this.hiSentTo.clear();
2246
+ this.lastHeard.clear();
2104
2247
  this.lockstep.becomeHost();
2105
2248
  this.emit("hostChanged", this.localPeerId);
2106
2249
  if (this.serverUp) this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
@@ -2224,6 +2367,8 @@ var OneLobby = (() => {
2224
2367
  }
2225
2368
  this.connected.delete(peerId);
2226
2369
  this.outbox.delete(peerId);
2370
+ this.lastHeard.delete(peerId);
2371
+ this.rate.delete(peerId);
2227
2372
  this.lockstep.onPeerLeft(peerId);
2228
2373
  this.chat.onPeerLeft(peerId);
2229
2374
  this.voice.onPeerLeft(peerId);
@@ -2255,6 +2400,9 @@ var OneLobby = (() => {
2255
2400
  }
2256
2401
  // ---- housekeeping ----------------------------------------------------------------------------------------------
2257
2402
  watchdog() {
2403
+ if (this.ended) return;
2404
+ const timeout = this.cfg.peerTimeoutMs ?? 0;
2405
+ if (timeout > 0) this.dropSilent(Math.max(timeout, MIN_PEER_TIMEOUT_MS));
2258
2406
  if (this.ended || this.isHost) return;
2259
2407
  if (this.mode === "lockstep" && this.lockstep.hostSilent()) {
2260
2408
  this.lockstep.touch();
@@ -2262,6 +2410,22 @@ var OneLobby = (() => {
2262
2410
  this.handleHostLost("timeout");
2263
2411
  }
2264
2412
  }
2413
+ /** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
2414
+ dropSilent(timeout) {
2415
+ const now = this.cfg.clock.now();
2416
+ if (this.isHost) {
2417
+ for (const id of [...this.connected]) {
2418
+ const last = this.lastHeard.get(id);
2419
+ if (last !== void 0 && now - last > timeout) this.removePeer(id, "timeout");
2420
+ }
2421
+ } else if (this.isReady) {
2422
+ const last = this.lastHeard.get(this.hostPeerId);
2423
+ if (last !== void 0 && now - last > timeout) {
2424
+ this.lastHeard.delete(this.hostPeerId);
2425
+ this.handleHostLost("timeout");
2426
+ }
2427
+ }
2428
+ }
2265
2429
  pingHost() {
2266
2430
  if (this.ended || this.isHost) return;
2267
2431
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
@@ -2306,7 +2470,7 @@ var OneLobby = (() => {
2306
2470
  var webCodecsOpus = {
2307
2471
  createEncoder(opts, onFrame) {
2308
2472
  let ts = 0;
2309
- const enc3 = new AudioEncoder({
2473
+ const enc4 = new AudioEncoder({
2310
2474
  output: (chunk) => {
2311
2475
  const out = new Uint8Array(chunk.byteLength);
2312
2476
  chunk.copyTo(out);
@@ -2314,7 +2478,7 @@ var OneLobby = (() => {
2314
2478
  },
2315
2479
  error: (e) => console.error("onelobby: AudioEncoder", e)
2316
2480
  });
2317
- enc3.configure({
2481
+ enc4.configure({
2318
2482
  codec: "opus",
2319
2483
  sampleRate: SAMPLE_RATE,
2320
2484
  numberOfChannels: 1,
@@ -2332,17 +2496,17 @@ var OneLobby = (() => {
2332
2496
  data: pcm
2333
2497
  });
2334
2498
  ts += 2e4;
2335
- enc3.encode(data);
2499
+ enc4.encode(data);
2336
2500
  data.close();
2337
2501
  },
2338
2502
  close() {
2339
- if (enc3.state !== "closed") enc3.close();
2503
+ if (enc4.state !== "closed") enc4.close();
2340
2504
  }
2341
2505
  };
2342
2506
  },
2343
2507
  createDecoder(onPcm) {
2344
2508
  let ts = 0;
2345
- const dec3 = new AudioDecoder({
2509
+ const dec4 = new AudioDecoder({
2346
2510
  output: (audio) => {
2347
2511
  const pcm = new Float32Array(audio.numberOfFrames);
2348
2512
  audio.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
@@ -2351,18 +2515,18 @@ var OneLobby = (() => {
2351
2515
  },
2352
2516
  error: (e) => console.error("onelobby: AudioDecoder", e)
2353
2517
  });
2354
- dec3.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
2518
+ dec4.configure({ codec: "opus", sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
2355
2519
  return {
2356
2520
  decode(opus) {
2357
2521
  if (opus === null) {
2358
2522
  onPcm(new Float32Array(FRAME_SAMPLES));
2359
2523
  return;
2360
2524
  }
2361
- dec3.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
2525
+ dec4.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
2362
2526
  ts += 2e4;
2363
2527
  },
2364
2528
  close() {
2365
- if (dec3.state !== "closed") dec3.close();
2529
+ if (dec4.state !== "closed") dec4.close();
2366
2530
  }
2367
2531
  };
2368
2532
  }
@@ -2672,7 +2836,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2672
2836
 
2673
2837
  // src/onelobby.ts
2674
2838
  var LIB_NAME = "onelobby";
2675
- var LIB_VERSION = "0.1.0";
2839
+ var LIB_VERSION = "0.2.0";
2676
2840
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2677
2841
  function hasMic() {
2678
2842
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -2712,7 +2876,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2712
2876
  hashEveryTicks: o.hashEveryTicks ?? tickRate,
2713
2877
  displayName: (o.displayName ?? "").trim(),
2714
2878
  relayOnly: o.relayOnly ?? false,
2715
- readyTimeoutMs: o.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
2879
+ readyTimeoutMs: o.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
2880
+ peerTimeoutMs: o.peerTimeoutMs ?? 0
2716
2881
  };
2717
2882
  const a = o.advanced ?? {};
2718
2883
  const pick = (k) => a[k] !== void 0 ? a[k] : o[k];
@@ -2821,7 +2986,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2821
2986
  throw e;
2822
2987
  }
2823
2988
  }
2824
- makeLobby(sig, welcome, state, direct) {
2989
+ makeLobby(sig, welcome, state, direct, rateLimit) {
2825
2990
  const lobby = new Lobby({
2826
2991
  signaling: sig,
2827
2992
  state,
@@ -2834,7 +2999,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2834
2999
  relayOnly: state.relayOnly ?? this.options.relayOnly,
2835
3000
  displayName: this.options.displayName,
2836
3001
  voice: { codec: this.voiceCodec, createSource: this.createMic, createSink: this.createSink },
2837
- readyTimeoutMs: this.options.readyTimeoutMs
3002
+ readyTimeoutMs: this.options.readyTimeoutMs,
3003
+ peerTimeoutMs: this.options.peerTimeoutMs,
3004
+ rateLimit
2838
3005
  });
2839
3006
  lobby.errorSink = (e) => this.emit("error", e);
2840
3007
  if (this.storage && !direct) {
@@ -2865,7 +3032,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2865
3032
  },
2866
3033
  "lobby"
2867
3034
  );
2868
- return this.makeLobby(sig, welcome, state);
3035
+ return this.makeLobby(sig, welcome, state, void 0, h.rateLimit);
2869
3036
  } catch (e) {
2870
3037
  sig.close();
2871
3038
  throw e;
@@ -2936,7 +3103,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2936
3103
  },
2937
3104
  "lobby"
2938
3105
  );
2939
- return this.makeLobby(sig, welcome, state, { links: hooks.wrapLinks(this.linkFactory) });
3106
+ return this.makeLobby(sig, welcome, state, { links: hooks.wrapLinks(this.linkFactory) }, h.rateLimit);
2940
3107
  } catch (e) {
2941
3108
  sig.close();
2942
3109
  throw e;
@@ -3069,9 +3236,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3069
3236
  var KeepaliveError = OneLobbyError;
3070
3237
 
3071
3238
  // src/ui/controller.ts
3072
- var enc2 = new TextEncoder();
3073
- var dec2 = new TextDecoder();
3074
- var toBytes = (w) => enc2.encode(JSON.stringify(w));
3239
+ var enc3 = new TextEncoder();
3240
+ var dec3 = new TextDecoder();
3241
+ var toBytes = (w) => enc3.encode(JSON.stringify(w));
3075
3242
  var SharedState = class {
3076
3243
  constructor(c) {
3077
3244
  this.c = c;
@@ -3312,7 +3479,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3312
3479
  onRaw(from, bytes) {
3313
3480
  let w;
3314
3481
  try {
3315
- w = JSON.parse(dec2.decode(bytes));
3482
+ w = JSON.parse(dec3.decode(bytes));
3316
3483
  } catch {
3317
3484
  return;
3318
3485
  }