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/index.d.ts
CHANGED
|
@@ -8,7 +8,10 @@ export type { OneLobbyOptions, HostOptions, ListOptions, OneLobbyEvents, JoinDir
|
|
|
8
8
|
export { DEFAULT_READY_TIMEOUT_MS } from "./onelobby.js";
|
|
9
9
|
export type { AdvancedOptions, SeatStorage } from "./onelobby.js";
|
|
10
10
|
export { Lobby } from "./lobby.js";
|
|
11
|
-
export type { LobbyEvents, LobbyConfig, Peer, Phase } from "./lobby.js";
|
|
11
|
+
export type { LobbyEvents, LobbyConfig, Peer, Phase, RateLimit } from "./lobby.js";
|
|
12
|
+
export { MIN_PEER_TIMEOUT_MS } from "./lobby.js";
|
|
13
|
+
export { closeCodeFor } from "./socket.js";
|
|
14
|
+
export type { LobbySocket, LobbySocketOptions } from "./socket.js";
|
|
12
15
|
export { Lockstep } from "./lockstep.js";
|
|
13
16
|
export type { Frame, DesyncInfo, LockstepEvents } from "./lockstep.js";
|
|
14
17
|
export { Chat, CHAT_MAX_BYTES } from "./chat.js";
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
export { OneLobby, LIB_NAME, LIB_VERSION } from "./onelobby.js";
|
|
7
7
|
export { DEFAULT_READY_TIMEOUT_MS } from "./onelobby.js";
|
|
8
8
|
export { Lobby } from "./lobby.js";
|
|
9
|
+
export { MIN_PEER_TIMEOUT_MS } from "./lobby.js";
|
|
10
|
+
// WebSocket-shaped sockets over a lobby: lobby.asWebSocket() (joiner), lobby.onConnection(conn => ...) (host).
|
|
11
|
+
export { closeCodeFor } from "./socket.js";
|
|
9
12
|
export { Lockstep } from "./lockstep.js";
|
|
10
13
|
export { Chat, CHAT_MAX_BYTES } from "./chat.js";
|
|
11
14
|
export { Voice } from "./voice.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAEzD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAInC,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC3D,iHAAiH;AACjH,8GAA8G;AAC9G,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElE,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAK/C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAErE,yEAAyE;AACzE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC","sourcesContent":["/**\n * Public API of `onelobby`: create a `OneLobby`, `host()` / `join(code)` / `list()` lobbies, then use the\n * `Lobby`'s lockstep, raw messages, chat and voice. Lower-level pieces (codec, chunking, migration, jitter buffer,\n * WebRTC/signaling classes, clocks, the gridpaint demo game) live in `onelobby/internals`.\n */\nexport { OneLobby, LIB_NAME, LIB_VERSION } from \"./onelobby.js\";\nexport type { OneLobbyOptions, HostOptions, ListOptions, OneLobbyEvents, JoinDirectOptions } from \"./onelobby.js\";\nexport { DEFAULT_READY_TIMEOUT_MS } from \"./onelobby.js\";\nexport type { AdvancedOptions, SeatStorage } from \"./onelobby.js\";\nexport { Lobby } from \"./lobby.js\";\nexport type { LobbyEvents, LobbyConfig, Peer, Phase } from \"./lobby.js\";\nexport { Lockstep } from \"./lockstep.js\";\nexport type { Frame, DesyncInfo, LockstepEvents } from \"./lockstep.js\";\nexport { Chat, CHAT_MAX_BYTES } from \"./chat.js\";\nexport type { ChatMessage, ChatEvents } from \"./chat.js\";\nexport { Voice } from \"./voice.js\";\nexport type { VoiceEvents, VoiceDeps } from \"./voice.js\";\nexport type { VoiceMode } from \"./voice-core.js\";\nexport type { VoiceCodec, VoiceEncoderHandle, VoiceDecoderHandle, FrameSource, AudioSink, VoiceError } from \"./voice-types.js\";\nexport { webCodecsOpusAvailable } from \"./voice-browser.js\";\nexport { normalizeCode, formatCode } from \"./crockford.js\";\n// Direct IP (docs/direct-ip.md): kit.joinDirect(code); the code is the host's address in disguise. Hosting is in\n// onelobby/direct (Node). webRtcLinkFactoryFor gives Node joiners a WebRTC stack (node-datachannel/polyfill).\nexport { decodeJoinCode, looksLikeJoinCode } from \"./joincode.js\";\nexport type { DecodedJoinCode, JoinCodeError } from \"./joincode.js\";\nexport { webRtcLinkFactoryFor } from \"./webrtc.js\";\nexport { OneLobbyError } from \"./signaling.js\";\nexport type { LobbySummary, LobbyState, PeerInfo } from \"./signaling.js\";\n// Types for the seams in OneLobbyOptions (ChannelName also tags raw messages).\nexport type { PeerLink, LinkFactory, SocketFactory, SocketLike, IceServer, ChannelName } from \"./transport.js\";\nexport type { Clock } from \"./clock.js\";\nexport { describeError, shareUrl, codeFromUrl } from \"./friendly.js\";\nexport type { FriendlyError } from \"./friendly.js\";\n// Pre-rename names, kept for one version so existing games keep working.\nexport { Keepalive, KeepaliveError } from \"./deprecated.js\";\nexport type { KeepaliveOptions, KeepaliveEvents } from \"./deprecated.js\";\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAEzD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAEnC,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACjD,+GAA+G;AAC/G,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAInC,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC3D,iHAAiH;AACjH,8GAA8G;AAC9G,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAElE,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAK/C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAErE,yEAAyE;AACzE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC","sourcesContent":["/**\n * Public API of `onelobby`: create a `OneLobby`, `host()` / `join(code)` / `list()` lobbies, then use the\n * `Lobby`'s lockstep, raw messages, chat and voice. Lower-level pieces (codec, chunking, migration, jitter buffer,\n * WebRTC/signaling classes, clocks, the gridpaint demo game) live in `onelobby/internals`.\n */\nexport { OneLobby, LIB_NAME, LIB_VERSION } from \"./onelobby.js\";\nexport type { OneLobbyOptions, HostOptions, ListOptions, OneLobbyEvents, JoinDirectOptions } from \"./onelobby.js\";\nexport { DEFAULT_READY_TIMEOUT_MS } from \"./onelobby.js\";\nexport type { AdvancedOptions, SeatStorage } from \"./onelobby.js\";\nexport { Lobby } from \"./lobby.js\";\nexport type { LobbyEvents, LobbyConfig, Peer, Phase, RateLimit } from \"./lobby.js\";\nexport { MIN_PEER_TIMEOUT_MS } from \"./lobby.js\";\n// WebSocket-shaped sockets over a lobby: lobby.asWebSocket() (joiner), lobby.onConnection(conn => ...) (host).\nexport { closeCodeFor } from \"./socket.js\";\nexport type { LobbySocket, LobbySocketOptions } from \"./socket.js\";\nexport { Lockstep } from \"./lockstep.js\";\nexport type { Frame, DesyncInfo, LockstepEvents } from \"./lockstep.js\";\nexport { Chat, CHAT_MAX_BYTES } from \"./chat.js\";\nexport type { ChatMessage, ChatEvents } from \"./chat.js\";\nexport { Voice } from \"./voice.js\";\nexport type { VoiceEvents, VoiceDeps } from \"./voice.js\";\nexport type { VoiceMode } from \"./voice-core.js\";\nexport type { VoiceCodec, VoiceEncoderHandle, VoiceDecoderHandle, FrameSource, AudioSink, VoiceError } from \"./voice-types.js\";\nexport { webCodecsOpusAvailable } from \"./voice-browser.js\";\nexport { normalizeCode, formatCode } from \"./crockford.js\";\n// Direct IP (docs/direct-ip.md): kit.joinDirect(code); the code is the host's address in disguise. Hosting is in\n// onelobby/direct (Node). webRtcLinkFactoryFor gives Node joiners a WebRTC stack (node-datachannel/polyfill).\nexport { decodeJoinCode, looksLikeJoinCode } from \"./joincode.js\";\nexport type { DecodedJoinCode, JoinCodeError } from \"./joincode.js\";\nexport { webRtcLinkFactoryFor } from \"./webrtc.js\";\nexport { OneLobbyError } from \"./signaling.js\";\nexport type { LobbySummary, LobbyState, PeerInfo } from \"./signaling.js\";\n// Types for the seams in OneLobbyOptions (ChannelName also tags raw messages).\nexport type { PeerLink, LinkFactory, SocketFactory, SocketLike, IceServer, ChannelName } from \"./transport.js\";\nexport type { Clock } from \"./clock.js\";\nexport { describeError, shareUrl, codeFromUrl } from \"./friendly.js\";\nexport type { FriendlyError } from \"./friendly.js\";\n// Pre-rename names, kept for one version so existing games keep working.\nexport { Keepalive, KeepaliveError } from \"./deprecated.js\";\nexport type { KeepaliveOptions, KeepaliveEvents } from \"./deprecated.js\";\n"]}
|
package/dist/lobby.d.ts
CHANGED
|
@@ -6,7 +6,22 @@ import { Lockstep } from "./lockstep.js";
|
|
|
6
6
|
import { type LobbyState, type PeerInfo, type SignalingClient } from "./signaling.js";
|
|
7
7
|
import type { ChannelName, IceServer, LinkFactory, PeerLink } from "./transport.js";
|
|
8
8
|
import { Voice, type VoiceDeps } from "./voice.js";
|
|
9
|
+
import { type LobbySocket, type LobbySocketOptions } from "./socket.js";
|
|
9
10
|
export type Phase = "lobby" | "in_game";
|
|
11
|
+
/** The close code and reason a host chose with `kick()` / `disconnect()`, passed to the removed player's sockets. */
|
|
12
|
+
export interface CloseInfo {
|
|
13
|
+
code?: number;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Options for `lobby.kick()`. */
|
|
17
|
+
export interface KickOptions {
|
|
18
|
+
/** Default true: the player's address cannot rejoin this lobby while it lasts. false = `disconnect()`. */
|
|
19
|
+
ban?: boolean;
|
|
20
|
+
/** Close code for the player's `asWebSocket()` onclose: 1000 or 3000-4999. */
|
|
21
|
+
code?: number;
|
|
22
|
+
/** Close reason for that onclose, at most 120 characters. */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
10
25
|
export type Peer = PeerInfo;
|
|
11
26
|
export type LobbyEvents = {
|
|
12
27
|
peerJoined: [Peer];
|
|
@@ -14,12 +29,14 @@ export type LobbyEvents = {
|
|
|
14
29
|
peerReady: [Peer];
|
|
15
30
|
/** Joiner: the link to the host is open (the host itself is ready from the start). Also available as `lobby.ready`. */
|
|
16
31
|
ready: [];
|
|
17
|
-
|
|
32
|
+
/** reason "left", "kicked", "disconnected" (kick with ban false), "timeout", ...; `close` when the host gave one. */
|
|
33
|
+
peerLeft: [Peer, reason: string, close?: CloseInfo];
|
|
18
34
|
joinRequested: [Peer];
|
|
19
35
|
hostChanged: [hostId: number];
|
|
20
36
|
phase: [Phase];
|
|
21
37
|
message: [from: number, bytes: Uint8Array, channel: ChannelName];
|
|
22
|
-
|
|
38
|
+
/** `close` is the code and reason the host gave when it removed this player (`kick()` / `disconnect()`). */
|
|
39
|
+
sessionEnded: [reason: string, close?: CloseInfo];
|
|
23
40
|
/** The signaling server went away. The game continues on the data channels (s.9). */
|
|
24
41
|
serverLost: [];
|
|
25
42
|
metaChanged: [Record<string, string>];
|
|
@@ -46,7 +63,25 @@ export interface LobbyConfig {
|
|
|
46
63
|
* (link, peer_left, a host_changed from the hub as it shuts down, or the signaling socket) ends with "host_left".
|
|
47
64
|
*/
|
|
48
65
|
direct?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Treat a peer as gone after this many ms without hearing anything from it (0 or absent: off, and a silent peer
|
|
68
|
+
* surfaces only when its link or signaling socket times out, about 30 s). Joiners ping the host every 2 s and the
|
|
69
|
+
* host answers, so the host drops a silent joiner (`peerLeft`, "timeout") and a joiner handles a silent host as
|
|
70
|
+
* lost (a direct lobby ends with "host_left"). Values under 6000 are raised to 6000.
|
|
71
|
+
*/
|
|
72
|
+
peerTimeoutMs?: number;
|
|
73
|
+
/** Host: per-peer cap on incoming raw messages; the excess is dropped before `message` fires or is relayed. */
|
|
74
|
+
rateLimit?: RateLimit;
|
|
49
75
|
}
|
|
76
|
+
/** At most `messages` raw messages per peer in any `perMs` window (a fixed window, reset every `perMs`). */
|
|
77
|
+
export interface RateLimit {
|
|
78
|
+
messages: number;
|
|
79
|
+
perMs: number;
|
|
80
|
+
}
|
|
81
|
+
/** The smallest `peerTimeoutMs` honoured: three missed pings. */
|
|
82
|
+
export declare const MIN_PEER_TIMEOUT_MS = 6000;
|
|
83
|
+
/** Host: how long a kicked / disconnected peer's link stays open for the hub's notice to reach it first. */
|
|
84
|
+
export declare const KICK_LINGER_MS = 3000;
|
|
50
85
|
/** The name shown for a peer that chose none: "Player N", N = its seat (peer id, join order). */
|
|
51
86
|
export declare function seatName(p: PeerInfo): PeerInfo;
|
|
52
87
|
/** One joined lobby: roster, star of peer links, and the lockstep/chat/voice engines riding on them. */
|
|
@@ -96,16 +131,31 @@ export declare class Lobby extends Emitter<LobbyEvents> {
|
|
|
96
131
|
private isReady;
|
|
97
132
|
private cancelReadyTimer;
|
|
98
133
|
private relayRetry;
|
|
134
|
+
/** clock.now() of the last frame from each peer (peerTimeoutMs). */
|
|
135
|
+
private lastHeard;
|
|
136
|
+
/** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
|
|
137
|
+
private readonly lingering;
|
|
138
|
+
/** Host: per-peer fixed window for rateLimit. */
|
|
139
|
+
private rate;
|
|
99
140
|
/**
|
|
100
141
|
* Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
|
|
101
142
|
* joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
|
|
102
|
-
* (or with "closed"/"kicked" when the lobby ends first).
|
|
143
|
+
* (or with "closed"/"kicked" when the lobby ends first; "closed" also when the host disconnected it).
|
|
103
144
|
*/
|
|
104
145
|
readonly ready: Promise<void>;
|
|
105
146
|
constructor(cfg: LobbyConfig);
|
|
106
147
|
get isHost(): boolean;
|
|
107
148
|
get peers(): Peer[];
|
|
108
149
|
peer(peerId: number): Peer | undefined;
|
|
150
|
+
/** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
|
|
151
|
+
get connectedPeers(): number[];
|
|
152
|
+
/**
|
|
153
|
+
* Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
|
|
154
|
+
* unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
|
|
155
|
+
*/
|
|
156
|
+
asWebSocket(opts?: LobbySocketOptions): LobbySocket;
|
|
157
|
+
/** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
|
|
158
|
+
onConnection(handler: (conn: LobbySocket) => void, opts?: LobbySocketOptions): () => void;
|
|
109
159
|
set snapshotChunkSize(n: number);
|
|
110
160
|
/** Current migration epoch (0 until the first host change). */
|
|
111
161
|
get hostEpoch(): number;
|
|
@@ -117,7 +167,18 @@ export declare class Lobby extends Emitter<LobbyEvents> {
|
|
|
117
167
|
_begin(): void;
|
|
118
168
|
start(): Promise<void>;
|
|
119
169
|
setMeta(meta: Record<string, string>): Promise<void>;
|
|
120
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Host: removes a player. By default it is a ban: the hub refuses that player's address for the rest of this lobby
|
|
172
|
+
* (a new tab or a reload included). `{ ban: false }` is `disconnect()`. `code` / `reason` reach the player's
|
|
173
|
+
* `asWebSocket()` onclose (and `sessionEnded`'s second argument); without them its socket closes with 4000 "kicked".
|
|
174
|
+
*/
|
|
175
|
+
kick(peerId: number, opts?: KickOptions): Promise<void>;
|
|
176
|
+
/**
|
|
177
|
+
* Host: ends one player's connection without a ban, like a server closing one WebSocket. The player's
|
|
178
|
+
* `asWebSocket()` gets onclose with `code` (default 1000) and `reason` (default "disconnected"), its lobby ends with
|
|
179
|
+
* `sessionEnded("disconnected")`, and it may join again (a new tab, a reload). Same as `kick(peerId, { ban: false, code, reason })`.
|
|
180
|
+
*/
|
|
181
|
+
disconnect(peerId: number, code?: number, reason?: string): Promise<void>;
|
|
121
182
|
approve(peerId: number, ok: boolean): Promise<void>;
|
|
122
183
|
leave(): Promise<void>;
|
|
123
184
|
/** raw mode (and any extra game data): to one peer. */
|
|
@@ -150,6 +211,7 @@ export declare class Lobby extends Emitter<LobbyEvents> {
|
|
|
150
211
|
private onHi;
|
|
151
212
|
private flush;
|
|
152
213
|
private onRaw;
|
|
214
|
+
private allowRaw;
|
|
153
215
|
private setHost;
|
|
154
216
|
/** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
|
|
155
217
|
private handleHostLost;
|
|
@@ -157,11 +219,19 @@ export declare class Lobby extends Emitter<LobbyEvents> {
|
|
|
157
219
|
private onHostClaim;
|
|
158
220
|
private onSignaling;
|
|
159
221
|
private onSignal;
|
|
222
|
+
/**
|
|
223
|
+
* `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
|
|
224
|
+
* or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
|
|
225
|
+
* like the host vanished (a migration and a 1001 instead of the host's close code).
|
|
226
|
+
*/
|
|
160
227
|
private removePeer;
|
|
228
|
+
/** Lockstep drops (stalled, desynced): removed without a ban, so the player can come back (a rejoin resyncs). */
|
|
161
229
|
private dropPeer;
|
|
162
230
|
private setPhase;
|
|
163
231
|
private onServerClosed;
|
|
164
232
|
private watchdog;
|
|
233
|
+
/** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
|
|
234
|
+
private dropSilent;
|
|
165
235
|
private pingHost;
|
|
166
236
|
private teardown;
|
|
167
237
|
/** Total in-flight links (diagnostics and tests). */
|
package/dist/lobby.js
CHANGED
|
@@ -6,6 +6,11 @@ import { Lockstep } from "./lockstep.js";
|
|
|
6
6
|
import { claimWinsWrapped, pickNewHost } from "./migration.js";
|
|
7
7
|
import { OneLobbyError } from "./signaling.js";
|
|
8
8
|
import { Voice } from "./voice.js";
|
|
9
|
+
import { hostConnections, joinerSocket } from "./socket.js";
|
|
10
|
+
/** The smallest `peerTimeoutMs` honoured: three missed pings. */
|
|
11
|
+
export const MIN_PEER_TIMEOUT_MS = 6000;
|
|
12
|
+
/** Host: how long a kicked / disconnected peer's link stays open for the hub's notice to reach it first. */
|
|
13
|
+
export const KICK_LINGER_MS = 3000;
|
|
9
14
|
/** The name shown for a peer that chose none: "Player N", N = its seat (peer id, join order). */
|
|
10
15
|
export function seatName(p) {
|
|
11
16
|
const n = typeof p.name === "string" ? p.name.trim() : "";
|
|
@@ -66,10 +71,16 @@ export class Lobby extends Emitter {
|
|
|
66
71
|
isReady = false;
|
|
67
72
|
cancelReadyTimer = null;
|
|
68
73
|
relayRetry = false;
|
|
74
|
+
/** clock.now() of the last frame from each peer (peerTimeoutMs). */
|
|
75
|
+
lastHeard = new Map();
|
|
76
|
+
/** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
|
|
77
|
+
lingering = new Set();
|
|
78
|
+
/** Host: per-peer fixed window for rateLimit. */
|
|
79
|
+
rate = new Map();
|
|
69
80
|
/**
|
|
70
81
|
* Resolves when this peer can exchange data: immediately for the host, once the link to the host is open for a
|
|
71
82
|
* joiner. Rejects with OneLobbyError("ice_failed") when the joiner cannot reach the host in `readyTimeoutMs`
|
|
72
|
-
* (or with "closed"/"kicked" when the lobby ends first).
|
|
83
|
+
* (or with "closed"/"kicked" when the lobby ends first; "closed" also when the host disconnected it).
|
|
73
84
|
*/
|
|
74
85
|
ready = new Promise((res, rej) => {
|
|
75
86
|
this.markReady = () => {
|
|
@@ -145,6 +156,21 @@ export class Lobby extends Emitter {
|
|
|
145
156
|
peer(peerId) {
|
|
146
157
|
return this.roster.get(peerId);
|
|
147
158
|
}
|
|
159
|
+
/** Peers whose link is open and handshake done (host: the ready joiners; joiner: the host once ready). */
|
|
160
|
+
get connectedPeers() {
|
|
161
|
+
return this.connectedPeerIds();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
|
|
165
|
+
* unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
|
|
166
|
+
*/
|
|
167
|
+
asWebSocket(opts) {
|
|
168
|
+
return joinerSocket(this, opts);
|
|
169
|
+
}
|
|
170
|
+
/** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
|
|
171
|
+
onConnection(handler, opts) {
|
|
172
|
+
return hostConnections(this, handler, opts);
|
|
173
|
+
}
|
|
148
174
|
set snapshotChunkSize(n) {
|
|
149
175
|
this.lockstep.chunkSize = n;
|
|
150
176
|
}
|
|
@@ -183,11 +209,37 @@ export class Lobby extends Emitter {
|
|
|
183
209
|
this.signaling.send({ t: "meta", meta });
|
|
184
210
|
this.emit("metaChanged", this.meta);
|
|
185
211
|
}
|
|
186
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Host: removes a player. By default it is a ban: the hub refuses that player's address for the rest of this lobby
|
|
214
|
+
* (a new tab or a reload included). `{ ban: false }` is `disconnect()`. `code` / `reason` reach the player's
|
|
215
|
+
* `asWebSocket()` onclose (and `sessionEnded`'s second argument); without them its socket closes with 4000 "kicked".
|
|
216
|
+
*/
|
|
217
|
+
async kick(peerId, opts = {}) {
|
|
187
218
|
if (!this.isHost)
|
|
188
219
|
throw new Error("only the host can kick");
|
|
189
|
-
|
|
190
|
-
|
|
220
|
+
const { ban = true, code, reason } = opts;
|
|
221
|
+
if (code !== undefined && !(code === 1000 || (Number.isInteger(code) && code >= 3000 && code <= 4999)))
|
|
222
|
+
throw new RangeError("close code must be 1000 or 3000-4999");
|
|
223
|
+
if (reason !== undefined && reason.length > 120)
|
|
224
|
+
throw new RangeError("close reason must be at most 120 characters");
|
|
225
|
+
const msg = { t: "kick", peerId };
|
|
226
|
+
if (!ban)
|
|
227
|
+
msg.ban = false;
|
|
228
|
+
if (code !== undefined)
|
|
229
|
+
msg.closeCode = code;
|
|
230
|
+
if (reason !== undefined)
|
|
231
|
+
msg.closeReason = reason;
|
|
232
|
+
this.signaling.send(msg);
|
|
233
|
+
const close = code !== undefined || reason !== undefined ? { code, reason } : undefined;
|
|
234
|
+
this.removePeer(peerId, ban ? "kicked" : "disconnected", close, true);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Host: ends one player's connection without a ban, like a server closing one WebSocket. The player's
|
|
238
|
+
* `asWebSocket()` gets onclose with `code` (default 1000) and `reason` (default "disconnected"), its lobby ends with
|
|
239
|
+
* `sessionEnded("disconnected")`, and it may join again (a new tab, a reload). Same as `kick(peerId, { ban: false, code, reason })`.
|
|
240
|
+
*/
|
|
241
|
+
disconnect(peerId, code, reason) {
|
|
242
|
+
return this.kick(peerId, { ban: false, code, reason });
|
|
191
243
|
}
|
|
192
244
|
async approve(peerId, ok) {
|
|
193
245
|
if (!this.isHost)
|
|
@@ -400,6 +452,8 @@ export class Lobby extends Emitter {
|
|
|
400
452
|
}
|
|
401
453
|
if (!d)
|
|
402
454
|
return;
|
|
455
|
+
if (!reassembled)
|
|
456
|
+
this.lastHeard.set(from, this.cfg.clock.now());
|
|
403
457
|
const { msg, route } = d;
|
|
404
458
|
switch (msg.type) {
|
|
405
459
|
case "hi":
|
|
@@ -497,6 +551,8 @@ export class Lobby extends Emitter {
|
|
|
497
551
|
this.emit("message", route, bytes, channel);
|
|
498
552
|
return;
|
|
499
553
|
}
|
|
554
|
+
if (!this.allowRaw(from))
|
|
555
|
+
return;
|
|
500
556
|
if (route === 0 || route === this.localPeerId) {
|
|
501
557
|
this.emit("message", from, bytes, channel);
|
|
502
558
|
}
|
|
@@ -509,6 +565,16 @@ export class Lobby extends Emitter {
|
|
|
509
565
|
this.sendTo(route, { type: "raw", bytes }, from, channel);
|
|
510
566
|
}
|
|
511
567
|
}
|
|
568
|
+
allowRaw(from) {
|
|
569
|
+
const lim = this.cfg.rateLimit;
|
|
570
|
+
if (!lim || !(lim.messages > 0) || !(lim.perMs > 0))
|
|
571
|
+
return true;
|
|
572
|
+
const now = this.cfg.clock.now();
|
|
573
|
+
let w = this.rate.get(from);
|
|
574
|
+
if (!w || now - w.start >= lim.perMs)
|
|
575
|
+
this.rate.set(from, (w = { start: now, n: 0 }));
|
|
576
|
+
return ++w.n <= lim.messages;
|
|
577
|
+
}
|
|
512
578
|
// ---- migration -------------------------------------------------------------------------------------------------
|
|
513
579
|
setHost(hostId) {
|
|
514
580
|
if (hostId === this.hostPeerId)
|
|
@@ -549,6 +615,7 @@ export class Lobby extends Emitter {
|
|
|
549
615
|
this.markReady();
|
|
550
616
|
this.connected.clear();
|
|
551
617
|
this.hiSentTo.clear();
|
|
618
|
+
this.lastHeard.clear(); // joiners spoke to the old host; their clocks start when they say hi to us
|
|
552
619
|
this.lockstep.becomeHost();
|
|
553
620
|
this.emit("hostChanged", this.localPeerId);
|
|
554
621
|
if (this.serverUp)
|
|
@@ -654,8 +721,15 @@ export class Lobby extends Emitter {
|
|
|
654
721
|
void this.onSignal(Number(m.from), m);
|
|
655
722
|
break;
|
|
656
723
|
case "error":
|
|
657
|
-
if (m.code === "kicked")
|
|
658
|
-
|
|
724
|
+
if (m.code === "kicked") {
|
|
725
|
+
const d = (m.data ?? {});
|
|
726
|
+
const close = {};
|
|
727
|
+
if (typeof d.closeCode === "number")
|
|
728
|
+
close.code = d.closeCode;
|
|
729
|
+
if (typeof d.closeReason === "string")
|
|
730
|
+
close.reason = d.closeReason;
|
|
731
|
+
this.teardown(d.ban === false ? "disconnected" : "kicked", close.code !== undefined || close.reason !== undefined ? close : undefined);
|
|
732
|
+
}
|
|
659
733
|
else
|
|
660
734
|
this.reportError(new OneLobbyError(String(m.code), String(m.msg ?? ""), m.data));
|
|
661
735
|
break;
|
|
@@ -676,7 +750,12 @@ export class Lobby extends Emitter {
|
|
|
676
750
|
payload.ice = s.ice;
|
|
677
751
|
await link.handleSignal(payload);
|
|
678
752
|
}
|
|
679
|
-
|
|
753
|
+
/**
|
|
754
|
+
* `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
|
|
755
|
+
* or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
|
|
756
|
+
* like the host vanished (a migration and a 1001 instead of the host's close code).
|
|
757
|
+
*/
|
|
758
|
+
removePeer(peerId, reason, close, linger = false) {
|
|
680
759
|
const peer = this.roster.get(peerId);
|
|
681
760
|
if (peerId === this.localPeerId) {
|
|
682
761
|
this.teardown(reason === "kicked" ? "kicked" : reason);
|
|
@@ -686,26 +765,43 @@ export class Lobby extends Emitter {
|
|
|
686
765
|
this.roster.delete(peerId);
|
|
687
766
|
const link = this.links.get(peerId);
|
|
688
767
|
if (link && peerId !== this.hostPeerId) {
|
|
689
|
-
link.onClose = null;
|
|
690
768
|
link.onMessage = null;
|
|
691
|
-
link.close();
|
|
692
769
|
this.links.delete(peerId);
|
|
693
770
|
this.hiSentTo.delete(link);
|
|
771
|
+
if (linger) {
|
|
772
|
+
this.lingering.add(link);
|
|
773
|
+
const done = () => {
|
|
774
|
+
if (!this.lingering.delete(link))
|
|
775
|
+
return;
|
|
776
|
+
cancel();
|
|
777
|
+
link.onClose = null;
|
|
778
|
+
link.close();
|
|
779
|
+
};
|
|
780
|
+
const cancel = this.cfg.clock.setTimeout(done, KICK_LINGER_MS);
|
|
781
|
+
link.onClose = done;
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
link.onClose = null;
|
|
785
|
+
link.close();
|
|
786
|
+
}
|
|
694
787
|
}
|
|
695
788
|
this.connected.delete(peerId);
|
|
696
789
|
this.outbox.delete(peerId);
|
|
790
|
+
this.lastHeard.delete(peerId);
|
|
791
|
+
this.rate.delete(peerId);
|
|
697
792
|
this.lockstep.onPeerLeft(peerId);
|
|
698
793
|
this.chat.onPeerLeft(peerId);
|
|
699
794
|
this.voice.onPeerLeft(peerId);
|
|
700
795
|
if (peer)
|
|
701
|
-
this.emit("peerLeft", peer, reason);
|
|
796
|
+
this.emit("peerLeft", peer, reason, close);
|
|
702
797
|
if (peerId === this.hostPeerId && !this.isHost) {
|
|
703
798
|
this.handleHostLost(reason);
|
|
704
799
|
}
|
|
705
800
|
}
|
|
801
|
+
/** Lockstep drops (stalled, desynced): removed without a ban, so the player can come back (a rejoin resyncs). */
|
|
706
802
|
dropPeer(peerId, reason) {
|
|
707
|
-
this.signaling.send({ t: "kick", peerId });
|
|
708
|
-
this.removePeer(peerId, reason);
|
|
803
|
+
this.signaling.send({ t: "kick", peerId, ban: false, closeReason: reason });
|
|
804
|
+
this.removePeer(peerId, reason, undefined, true);
|
|
709
805
|
}
|
|
710
806
|
setPhase(p) {
|
|
711
807
|
if (p !== "lobby" && p !== "in_game")
|
|
@@ -732,6 +828,11 @@ export class Lobby extends Emitter {
|
|
|
732
828
|
}
|
|
733
829
|
// ---- housekeeping ----------------------------------------------------------------------------------------------
|
|
734
830
|
watchdog() {
|
|
831
|
+
if (this.ended)
|
|
832
|
+
return;
|
|
833
|
+
const timeout = this.cfg.peerTimeoutMs ?? 0;
|
|
834
|
+
if (timeout > 0)
|
|
835
|
+
this.dropSilent(Math.max(timeout, MIN_PEER_TIMEOUT_MS));
|
|
735
836
|
if (this.ended || this.isHost)
|
|
736
837
|
return;
|
|
737
838
|
if (this.mode === "lockstep" && this.lockstep.hostSilent()) {
|
|
@@ -740,12 +841,30 @@ export class Lobby extends Emitter {
|
|
|
740
841
|
this.handleHostLost("timeout");
|
|
741
842
|
}
|
|
742
843
|
}
|
|
844
|
+
/** peerTimeoutMs: the host drops joiners it has not heard from; a joiner handles a silent host as lost. */
|
|
845
|
+
dropSilent(timeout) {
|
|
846
|
+
const now = this.cfg.clock.now();
|
|
847
|
+
if (this.isHost) {
|
|
848
|
+
for (const id of [...this.connected]) {
|
|
849
|
+
const last = this.lastHeard.get(id);
|
|
850
|
+
if (last !== undefined && now - last > timeout)
|
|
851
|
+
this.removePeer(id, "timeout");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
else if (this.isReady) {
|
|
855
|
+
const last = this.lastHeard.get(this.hostPeerId);
|
|
856
|
+
if (last !== undefined && now - last > timeout) {
|
|
857
|
+
this.lastHeard.delete(this.hostPeerId);
|
|
858
|
+
this.handleHostLost("timeout");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
743
862
|
pingHost() {
|
|
744
863
|
if (this.ended || this.isHost)
|
|
745
864
|
return;
|
|
746
865
|
this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
|
|
747
866
|
}
|
|
748
|
-
teardown(reason) {
|
|
867
|
+
teardown(reason, close) {
|
|
749
868
|
if (this.ended)
|
|
750
869
|
return;
|
|
751
870
|
this.ended = true;
|
|
@@ -758,16 +877,17 @@ export class Lobby extends Emitter {
|
|
|
758
877
|
this.cancelPing?.();
|
|
759
878
|
this.lockstep.stop();
|
|
760
879
|
this.voice.close();
|
|
761
|
-
for (const l of this.links.values()) {
|
|
880
|
+
for (const l of [...this.links.values(), ...this.lingering]) {
|
|
762
881
|
l.onClose = null;
|
|
763
882
|
l.onMessage = null;
|
|
764
883
|
l.close();
|
|
765
884
|
}
|
|
766
885
|
this.links.clear();
|
|
886
|
+
this.lingering.clear();
|
|
767
887
|
this.signaling.onClose = null;
|
|
768
888
|
this.signaling.onMessage = null;
|
|
769
889
|
this.signaling.close();
|
|
770
|
-
this.emit("sessionEnded", reason);
|
|
890
|
+
this.emit("sessionEnded", reason, close);
|
|
771
891
|
}
|
|
772
892
|
/** Total in-flight links (diagnostics and tests). */
|
|
773
893
|
get linkCount() {
|