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/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
- peerLeft: [Peer, reason: string];
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
- sessionEnded: [reason: string];
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
- kick(peerId: number): Promise<void>;
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
- async kick(peerId) {
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
- this.signaling.send({ t: "kick", peerId });
190
- this.removePeer(peerId, "kicked");
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
- this.teardown("kicked");
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
- removePeer(peerId, reason) {
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() {