onelobby 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { type LobbySummary } from "./signaling.js";
5
5
  import { type LinkFactory, type SocketFactory } from "./transport.js";
6
6
  import type { VoiceCodec, FrameSource, AudioSink } from "./voice-types.js";
7
7
  export declare const LIB_NAME = "onelobby";
8
- export declare const LIB_VERSION = "0.3.0";
8
+ export declare const LIB_VERSION = "0.4.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;
@@ -134,6 +134,8 @@ export declare class OneLobby extends Emitter<OneLobbyEvents> {
134
134
  resume(): Promise<Lobby | null>;
135
135
  private helloMsg;
136
136
  private connect;
137
+ /** The signaling socket only (no hello yet); with a timeout (direct IP), no answer is `direct_unreachable`. */
138
+ private open;
137
139
  private makeLobby;
138
140
  host(h: HostOptions): Promise<Lobby>;
139
141
  join(code: string): Promise<Lobby>;
@@ -144,8 +146,16 @@ export declare class OneLobby extends Emitter<OneLobbyEvents> {
144
146
  * The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
145
147
  */
146
148
  joinDirect(codeOrAddress: string, opts?: JoinDirectOptions): Promise<Lobby>;
149
+ /**
150
+ * Take a direct-IP seat back after a drop or a restart: the code (or address) joined before and that lobby's
151
+ * `resumeToken` (save it after every join; it changes on each resume). The host keeps a dropped seat for 60 s and the
152
+ * same peer id comes back. Rejects with `invalid_token` when the seat is gone: offer `joinDirect` then.
153
+ */
154
+ rejoinDirect(codeOrAddress: string, resumeToken: string, opts?: JoinDirectOptions): Promise<Lobby>;
155
+ private directTarget;
147
156
  /** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
148
157
  hostDirectWith(h: HostOptions, hooks: DirectLobbyHooks): Promise<Lobby>;
149
158
  rejoin(resumeToken: string): Promise<Lobby>;
159
+ private resumeAt;
150
160
  list(opts?: ListOptions): Promise<LobbySummary[]>;
151
161
  }
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.3.0 | MIT license */
1
+ /*! onelobby 0.4.1 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -25,6 +25,7 @@ var OneLobby = (() => {
25
25
  CHAT_MAX_BYTES: () => CHAT_MAX_BYTES,
26
26
  Chat: () => Chat,
27
27
  DEFAULT_READY_TIMEOUT_MS: () => DEFAULT_READY_TIMEOUT_MS,
28
+ ErrorCodes: () => ErrorCodes,
28
29
  Keepalive: () => Keepalive,
29
30
  KeepaliveError: () => KeepaliveError,
30
31
  LIB_NAME: () => LIB_NAME,
@@ -629,13 +630,17 @@ var OneLobby = (() => {
629
630
  }
630
631
  return out;
631
632
  }
633
+ var MAX_PARTIAL_SETS = 8;
632
634
  var Reassembler = class {
633
635
  parts = /* @__PURE__ */ new Map();
636
+ // insertion order = oldest first
634
637
  /** Returns the full payload when the last chunk arrives, otherwise null. */
635
638
  push(chunk) {
636
639
  if (chunk.count === 0 || chunk.index >= chunk.count) return null;
637
640
  let e = this.parts.get(chunk.id);
638
641
  if (!e || e.count !== chunk.count) {
642
+ this.parts.delete(chunk.id);
643
+ while (this.parts.size >= MAX_PARTIAL_SETS) this.parts.delete(this.parts.keys().next().value);
639
644
  e = { count: chunk.count, got: 0, parts: new Array(chunk.count), bytes: 0 };
640
645
  this.parts.set(chunk.id, e);
641
646
  }
@@ -647,7 +652,15 @@ var OneLobby = (() => {
647
652
  this.parts.delete(chunk.id);
648
653
  throw new CodecError("too_long", `chunk set over ${MAX_REASSEMBLED_BYTES} bytes`);
649
654
  }
650
- if (e.got < e.count) return null;
655
+ if (e.got < e.count) {
656
+ while (this.pendingBytes > MAX_REASSEMBLED_BYTES && this.parts.size > 1) {
657
+ for (const id of this.parts.keys()) if (id !== chunk.id) {
658
+ this.parts.delete(id);
659
+ break;
660
+ }
661
+ }
662
+ return null;
663
+ }
651
664
  this.parts.delete(chunk.id);
652
665
  const out = new Uint8Array(e.bytes);
653
666
  let pos = 0;
@@ -660,6 +673,12 @@ var OneLobby = (() => {
660
673
  get pending() {
661
674
  return this.parts.size;
662
675
  }
676
+ /** Bytes held across incomplete sets. */
677
+ get pendingBytes() {
678
+ let n = 0;
679
+ for (const p of this.parts.values()) n += p.bytes;
680
+ return n;
681
+ }
663
682
  clear() {
664
683
  this.parts.clear();
665
684
  }
@@ -669,9 +688,10 @@ var OneLobby = (() => {
669
688
  function deflateAvailable() {
670
689
  return typeof CompressionStream !== "undefined" && typeof DecompressionStream !== "undefined";
671
690
  }
672
- async function pipe(bytes, stream) {
691
+ async function pipe(bytes, stream, maxBytes = Infinity) {
673
692
  const w = stream.writable.getWriter();
674
- void w.write(bytes).then(() => w.close());
693
+ void w.write(bytes).then(() => w.close()).catch(() => {
694
+ });
675
695
  const chunks = [];
676
696
  const r = stream.readable.getReader();
677
697
  let total = 0;
@@ -680,6 +700,11 @@ var OneLobby = (() => {
680
700
  if (done) break;
681
701
  chunks.push(value);
682
702
  total += value.length;
703
+ if (total > maxBytes) {
704
+ void r.cancel().catch(() => {
705
+ });
706
+ throw new CodecError("too_long", `snapshot inflates to more than ${maxBytes} bytes`);
707
+ }
683
708
  }
684
709
  const out = new Uint8Array(total);
685
710
  let pos = 0;
@@ -692,8 +717,8 @@ var OneLobby = (() => {
692
717
  function deflate(bytes) {
693
718
  return pipe(bytes, new CompressionStream("deflate"));
694
719
  }
695
- function inflate(bytes) {
696
- return pipe(bytes, new DecompressionStream("deflate"));
720
+ function inflate(bytes, maxBytes = MAX_REASSEMBLED_BYTES) {
721
+ return pipe(bytes, new DecompressionStream("deflate"), maxBytes);
697
722
  }
698
723
 
699
724
  // src/lockstep.ts
@@ -701,6 +726,7 @@ var OneLobby = (() => {
701
726
  var MAX_RESTORE_RETRIES = 3;
702
727
  var HOST_SILENCE_MS = 3e3;
703
728
  var PEER_HASH_SILENCE_MS = 1e4;
729
+ var MAX_PENDING_PER_PEER = 256;
704
730
  var SNAPSHOT_COMPRESS_MIN = 1024;
705
731
  var Lockstep = class extends Emitter {
706
732
  constructor(hub, opts) {
@@ -732,6 +758,7 @@ var OneLobby = (() => {
732
758
  hosting = false;
733
759
  _started = false;
734
760
  pending = [];
761
+ _droppedCommands = 0;
735
762
  ready = /* @__PURE__ */ new Set();
736
763
  snapshotting = /* @__PURE__ */ new Set();
737
764
  cancelTicker = null;
@@ -746,6 +773,10 @@ var OneLobby = (() => {
746
773
  // joiner side
747
774
  pendingSnapshot = null;
748
775
  // ---- public game-facing surface -------------------------------------------------------------------------------
776
+ /** Remote commands the host dropped because that peer already had MAX_PENDING_PER_PEER waiting. */
777
+ get droppedCommands() {
778
+ return this._droppedCommands;
779
+ }
749
780
  get started() {
750
781
  return this._started;
751
782
  }
@@ -891,7 +922,12 @@ var OneLobby = (() => {
891
922
  handle(from, msg) {
892
923
  switch (msg.type) {
893
924
  case "cmd":
894
- if (this.hosting) this.pending.push({ peerId: from, bytes: msg.bytes });
925
+ if (this.hosting) {
926
+ let waiting = 0;
927
+ for (const c of this.pending) if (c.peerId === from) waiting++;
928
+ if (waiting >= MAX_PENDING_PER_PEER) this._droppedCommands++;
929
+ else this.pending.push({ peerId: from, bytes: msg.bytes });
930
+ }
895
931
  break;
896
932
  case "frame":
897
933
  if (!this.hosting) this.onFrame(msg.tick, msg.commands);
@@ -2980,7 +3016,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2980
3016
 
2981
3017
  // src/onelobby.ts
2982
3018
  var LIB_NAME = "onelobby";
2983
- var LIB_VERSION = "0.3.0";
3019
+ var LIB_VERSION = "0.4.1";
2984
3020
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2985
3021
  function hasMic() {
2986
3022
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -3105,6 +3141,17 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3105
3141
  };
3106
3142
  }
3107
3143
  async connect(url = this.options.lobbyServer, factory = this.socketFactory, timeoutMs = 0) {
3144
+ const sig = await this.open(url, factory, timeoutMs);
3145
+ try {
3146
+ const welcome = await sig.request(this.helloMsg(), "welcome");
3147
+ return { sig, welcome };
3148
+ } catch (e) {
3149
+ sig.close();
3150
+ throw e;
3151
+ }
3152
+ }
3153
+ /** The signaling socket only (no hello yet); with a timeout (direct IP), no answer is `direct_unreachable`. */
3154
+ async open(url, factory, timeoutMs) {
3108
3155
  const sig = new SignalingClient(url, factory, this.clock);
3109
3156
  if (timeoutMs > 0) {
3110
3157
  let cancel = () => {
@@ -3122,13 +3169,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3122
3169
  cancel();
3123
3170
  }
3124
3171
  } else await sig.connect();
3125
- try {
3126
- const welcome = await sig.request(this.helloMsg(), "welcome");
3127
- return { sig, welcome };
3128
- } catch (e) {
3129
- sig.close();
3130
- throw e;
3131
- }
3172
+ return sig;
3132
3173
  }
3133
3174
  makeLobby(sig, welcome, state, direct, rateLimit) {
3134
3175
  const lobby = new Lobby({
@@ -3210,6 +3251,30 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3210
3251
  * The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
3211
3252
  */
3212
3253
  async joinDirect(codeOrAddress, opts = {}) {
3254
+ const { addr, links } = this.directTarget(codeOrAddress, opts);
3255
+ const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3256
+ try {
3257
+ const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3258
+ const lobby = this.makeLobby(sig, welcome, state, { links });
3259
+ lobby.joinCode = joinedWith(addr);
3260
+ return lobby;
3261
+ } catch (e) {
3262
+ sig.close();
3263
+ throw e;
3264
+ }
3265
+ }
3266
+ /**
3267
+ * Take a direct-IP seat back after a drop or a restart: the code (or address) joined before and that lobby's
3268
+ * `resumeToken` (save it after every join; it changes on each resume). The host keeps a dropped seat for 60 s and the
3269
+ * same peer id comes back. Rejects with `invalid_token` when the seat is gone: offer `joinDirect` then.
3270
+ */
3271
+ async rejoinDirect(codeOrAddress, resumeToken, opts = {}) {
3272
+ const { addr, links } = this.directTarget(codeOrAddress, opts);
3273
+ const lobby = await this.resumeAt(resumeToken, addr.url, opts.connectTimeoutMs ?? 5e3, { links });
3274
+ lobby.joinCode = joinedWith(addr);
3275
+ return lobby;
3276
+ }
3277
+ directTarget(codeOrAddress, opts) {
3213
3278
  let input = codeOrAddress.trim();
3214
3279
  if (looksLikeJoinCode(input)) {
3215
3280
  const d = decodeJoinCode(input);
@@ -3221,16 +3286,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3221
3286
  if (!addr) throw new OneLobbyError("bad_address", `"${codeOrAddress}" is not a join code or an address (try 6B01-RGFE-P2B or 192.168.1.20:47810)`);
3222
3287
  const inner = this.linkFactory;
3223
3288
  const links = { create: (o) => inner.create({ ...o, iceServers: [], extraRemoteCandidates: [addr.candidate] }) };
3224
- const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3225
- try {
3226
- const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3227
- const lobby = this.makeLobby(sig, welcome, state, { links });
3228
- lobby.joinCode = joinedWith(addr);
3229
- return lobby;
3230
- } catch (e) {
3231
- sig.close();
3232
- throw e;
3233
- }
3289
+ return { addr, links };
3234
3290
  }
3235
3291
  /** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
3236
3292
  async hostDirectWith(h, hooks) {
@@ -3256,8 +3312,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3256
3312
  }
3257
3313
  }
3258
3314
  async rejoin(resumeToken) {
3259
- const sig = new SignalingClient(this.options.lobbyServer, this.socketFactory, this.clock);
3260
- await sig.connect();
3315
+ return this.resumeAt(resumeToken, this.options.lobbyServer, 0);
3316
+ }
3317
+ async resumeAt(resumeToken, url, timeoutMs, direct) {
3318
+ const sig = await this.open(url, this.socketFactory, timeoutMs);
3261
3319
  try {
3262
3320
  const lobbyP = sig.expect("lobby");
3263
3321
  const welcomeP = sig.request(this.helloMsg(resumeToken), "welcome");
@@ -3266,7 +3324,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3266
3324
  const welcome = await welcomeP;
3267
3325
  if (welcome.peerId === void 0) throw new OneLobbyError("invalid_token", "resume token unknown or expired");
3268
3326
  const state = await lobbyP;
3269
- return this.makeLobby(sig, welcome, state);
3327
+ return this.makeLobby(sig, welcome, state, direct);
3270
3328
  } catch (e) {
3271
3329
  sig.close();
3272
3330
  throw e;
@@ -3295,6 +3353,33 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3295
3353
  }
3296
3354
 
3297
3355
  // src/friendly.ts
3356
+ var ErrorCodes = {
3357
+ // from the lobby server or a direct host's hub
3358
+ UnknownCode: "unknown_code",
3359
+ BadCode: "bad_code",
3360
+ LobbyFull: "lobby_full",
3361
+ VersionMismatch: "version_mismatch",
3362
+ InGame: "in_game",
3363
+ Kicked: "kicked",
3364
+ BadPassword: "bad_password",
3365
+ NotHost: "not_host",
3366
+ RateLimited: "rate_limited",
3367
+ ServerFull: "server_full",
3368
+ GameNotAllowed: "game_not_allowed",
3369
+ BadRequest: "bad_request",
3370
+ // from this client
3371
+ ServerClosed: "server_closed",
3372
+ IceFailed: "ice_failed",
3373
+ Timeout: "timeout",
3374
+ DirectUnreachable: "direct_unreachable",
3375
+ BadAddress: "bad_address",
3376
+ PortInUse: "port_in_use",
3377
+ ConnectFailed: "connect_failed",
3378
+ Closed: "closed",
3379
+ ServerLost: "server_lost",
3380
+ InvalidToken: "invalid_token",
3381
+ NoServer: "no_server"
3382
+ };
3298
3383
  var TEXT = {
3299
3384
  // server errors (spec/signaling.md)
3300
3385
  unknown_code: { message: "We couldn't find a lobby with that code.", hint: "Check the code with the host. Codes stop working once everyone leaves." },