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.
- package/README.md +2 -2
- package/dist/direct/hub.js +23 -5
- package/dist/direct/hub.js.map +1 -1
- package/dist/direct/index.d.ts +11 -2
- package/dist/direct/index.js +18 -9
- package/dist/direct/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lobby.d.ts +74 -4
- package/dist/lobby.js +135 -15
- package/dist/lobby.js.map +1 -1
- package/dist/onelobby.bundle.mjs +5 -5
- package/dist/onelobby.bundle.mjs.map +4 -4
- package/dist/onelobby.d.ts +12 -3
- package/dist/onelobby.global.js +306 -35
- package/dist/onelobby.global.js.map +4 -4
- package/dist/onelobby.global.min.js +5 -5
- package/dist/onelobby.global.min.js.map +4 -4
- package/dist/onelobby.js +23 -6
- package/dist/onelobby.js.map +1 -1
- package/dist/socket.d.ts +69 -0
- package/dist/socket.js +166 -0
- package/dist/socket.js.map +1 -0
- package/dist/testing.js +13 -3
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/onelobby.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/onelobby.global.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! onelobby 0.1
|
|
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
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
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")
|
|
2195
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2570
|
+
enc4.encode(data);
|
|
2336
2571
|
data.close();
|
|
2337
2572
|
},
|
|
2338
2573
|
close() {
|
|
2339
|
-
if (
|
|
2574
|
+
if (enc4.state !== "closed") enc4.close();
|
|
2340
2575
|
}
|
|
2341
2576
|
};
|
|
2342
2577
|
},
|
|
2343
2578
|
createDecoder(onPcm) {
|
|
2344
2579
|
let ts = 0;
|
|
2345
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
2596
|
+
dec4.decode(new EncodedAudioChunk({ type: "key", timestamp: ts, data: opus }));
|
|
2362
2597
|
ts += 2e4;
|
|
2363
2598
|
},
|
|
2364
2599
|
close() {
|
|
2365
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
3073
|
-
var
|
|
3074
|
-
var toBytes = (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(
|
|
3586
|
+
w = JSON.parse(dec3.decode(bytes));
|
|
3316
3587
|
} catch {
|
|
3317
3588
|
return;
|
|
3318
3589
|
}
|