onelobby 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +2 -2
  2. package/blueprint.schema.json +8 -1
  3. package/dist/blueprint.d.ts +4 -0
  4. package/dist/blueprint.js +5 -0
  5. package/dist/blueprint.js.map +1 -1
  6. package/dist/codec.d.ts +23 -0
  7. package/dist/codec.js +28 -0
  8. package/dist/codec.js.map +1 -1
  9. package/dist/direct/browser-host.d.ts +46 -0
  10. package/dist/direct/browser-host.js +93 -0
  11. package/dist/direct/browser-host.js.map +1 -0
  12. package/dist/direct/hub-host.d.ts +34 -23
  13. package/dist/direct/hub-host.js +75 -59
  14. package/dist/direct/hub-host.js.map +1 -1
  15. package/dist/direct/hub-local.d.ts +22 -0
  16. package/dist/direct/hub-local.js +46 -0
  17. package/dist/direct/hub-local.js.map +1 -0
  18. package/dist/direct/hub.d.ts +39 -10
  19. package/dist/direct/hub.js +134 -24
  20. package/dist/direct/hub.js.map +1 -1
  21. package/dist/direct/index.d.ts +36 -3
  22. package/dist/direct/index.js +146 -20
  23. package/dist/direct/index.js.map +1 -1
  24. package/dist/direct/metrics.d.ts +12 -0
  25. package/dist/direct/metrics.js +45 -0
  26. package/dist/direct/metrics.js.map +1 -0
  27. package/dist/direct/portmap.d.ts +9 -2
  28. package/dist/direct/portmap.js +7 -2
  29. package/dist/direct/portmap.js.map +1 -1
  30. package/dist/direct/sha1.d.ts +16 -0
  31. package/dist/direct/sha1.js +111 -0
  32. package/dist/direct/sha1.js.map +1 -0
  33. package/dist/direct-address.d.ts +2 -2
  34. package/dist/direct-address.js.map +1 -1
  35. package/dist/friendly.d.ts +24 -0
  36. package/dist/friendly.js +30 -0
  37. package/dist/friendly.js.map +1 -1
  38. package/dist/index.d.ts +8 -4
  39. package/dist/index.js +6 -4
  40. package/dist/index.js.map +1 -1
  41. package/dist/internals.d.ts +9 -0
  42. package/dist/internals.js +9 -0
  43. package/dist/internals.js.map +1 -1
  44. package/dist/join-code-kind.d.ts +10 -0
  45. package/dist/join-code-kind.js +10 -0
  46. package/dist/join-code-kind.js.map +1 -0
  47. package/dist/joincode.d.ts +10 -1
  48. package/dist/joincode.js +23 -3
  49. package/dist/joincode.js.map +1 -1
  50. package/dist/lobby.d.ts +94 -5
  51. package/dist/lobby.js +177 -18
  52. package/dist/lobby.js.map +1 -1
  53. package/dist/lockstep.d.ts +8 -0
  54. package/dist/lockstep.js +23 -2
  55. package/dist/lockstep.js.map +1 -1
  56. package/dist/nostr/crypto.d.ts +20 -0
  57. package/dist/nostr/crypto.js +78 -0
  58. package/dist/nostr/crypto.js.map +1 -0
  59. package/dist/nostr/host.d.ts +66 -0
  60. package/dist/nostr/host.js +196 -0
  61. package/dist/nostr/host.js.map +1 -0
  62. package/dist/nostr/limits.d.ts +33 -0
  63. package/dist/nostr/limits.js +38 -0
  64. package/dist/nostr/limits.js.map +1 -0
  65. package/dist/nostr/link.d.ts +70 -0
  66. package/dist/nostr/link.js +180 -0
  67. package/dist/nostr/link.js.map +1 -0
  68. package/dist/nostr/relays.d.ts +79 -0
  69. package/dist/nostr/relays.js +325 -0
  70. package/dist/nostr/relays.js.map +1 -0
  71. package/dist/nostr/room.d.ts +43 -0
  72. package/dist/nostr/room.js +104 -0
  73. package/dist/nostr/room.js.map +1 -0
  74. package/dist/nostr/secp256k1.d.ts +10 -0
  75. package/dist/nostr/secp256k1.js +171 -0
  76. package/dist/nostr/secp256k1.js.map +1 -0
  77. package/dist/nostr/socket.d.ts +48 -0
  78. package/dist/nostr/socket.js +141 -0
  79. package/dist/nostr/socket.js.map +1 -0
  80. package/dist/onelobby.bundle.js +146 -0
  81. package/dist/onelobby.bundle.js.map +7 -0
  82. package/dist/onelobby.bundle.mjs +11 -5
  83. package/dist/onelobby.bundle.mjs.map +4 -4
  84. package/dist/onelobby.d.ts +31 -3
  85. package/dist/onelobby.global.js +2673 -60
  86. package/dist/onelobby.global.js.map +4 -4
  87. package/dist/onelobby.global.min.js +11 -5
  88. package/dist/onelobby.global.min.js.map +4 -4
  89. package/dist/onelobby.js +77 -14
  90. package/dist/onelobby.js.map +1 -1
  91. package/dist/pause.d.ts +85 -0
  92. package/dist/pause.js +160 -0
  93. package/dist/pause.js.map +1 -0
  94. package/dist/signaling.d.ts +2 -0
  95. package/dist/signaling.js.map +1 -1
  96. package/dist/socket.d.ts +15 -6
  97. package/dist/socket.js.map +1 -1
  98. package/dist/testing.d.ts +2 -0
  99. package/dist/testing.js +18 -2
  100. package/dist/testing.js.map +1 -1
  101. package/dist/transport.d.ts +5 -1
  102. package/dist/transport.js.map +1 -1
  103. package/dist/webrtc.d.ts +6 -0
  104. package/dist/webrtc.js +27 -3
  105. package/dist/webrtc.js.map +1 -1
  106. package/package.json +1 -1
  107. package/dist/deprecated.d.ts +0 -18
  108. package/dist/deprecated.js +0 -11
  109. package/dist/deprecated.js.map +0 -1
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.5.0 | MIT license */
1
+ /*! onelobby 0.7.0 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -17,7 +17,7 @@ var OneLobby = (() => {
17
17
  }
18
18
  return to;
19
19
  };
20
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+ var __toCommonJS = (mod2) => __copyProps(__defProp({}, "__esModule", { value: true }), mod2);
21
21
 
22
22
  // src/global.ts
23
23
  var global_exports = {};
@@ -25,10 +25,10 @@ var OneLobby = (() => {
25
25
  BlueprintError: () => BlueprintError,
26
26
  CHAT_MAX_BYTES: () => CHAT_MAX_BYTES,
27
27
  Chat: () => Chat,
28
+ DEFAULT_NOSTR_RELAYS: () => DEFAULT_NOSTR_RELAYS,
29
+ DEFAULT_NOSTR_STUN: () => DEFAULT_NOSTR_STUN,
28
30
  DEFAULT_READY_TIMEOUT_MS: () => DEFAULT_READY_TIMEOUT_MS,
29
31
  ErrorCodes: () => ErrorCodes,
30
- Keepalive: () => Keepalive,
31
- KeepaliveError: () => KeepaliveError,
32
32
  LIB_NAME: () => LIB_NAME,
33
33
  LIB_VERSION: () => LIB_VERSION,
34
34
  LOBBY_TAG: () => LOBBY_TAG,
@@ -38,14 +38,18 @@ var OneLobby = (() => {
38
38
  MIN_PEER_TIMEOUT_MS: () => MIN_PEER_TIMEOUT_MS,
39
39
  OneLobby: () => OneLobby,
40
40
  OneLobbyError: () => OneLobbyError,
41
+ Pause: () => Pause,
41
42
  SharedState: () => SharedState,
42
43
  Voice: () => Voice,
44
+ checkNostrRelays: () => checkNostrRelays,
43
45
  closeCodeFor: () => closeCodeFor,
44
46
  codeFromUrl: () => codeFromUrl,
45
47
  decodeJoinCode: () => decodeJoinCode,
46
48
  defineBlueprint: () => defineBlueprint,
47
49
  defineLobbyElement: () => defineLobbyElement,
48
50
  describeError: () => describeError,
51
+ directJoinInfo: () => directJoinInfo,
52
+ encodeRoomCode: () => encodeRoomCode,
49
53
  formatCode: () => formatCode,
50
54
  lobbyServerUrl: () => lobbyServerUrl,
51
55
  looksLikeJoinCode: () => looksLikeJoinCode,
@@ -159,7 +163,10 @@ var OneLobby = (() => {
159
163
  voice: 80,
160
164
  chat: 96,
161
165
  chat_history: 97,
162
- chat_reject: 98
166
+ chat_reject: 98,
167
+ pause_request: 112,
168
+ pause_state: 113,
169
+ pause_refuse: 114
163
170
  };
164
171
  var MODE_LOCKSTEP = 1;
165
172
  var MODE_RAW = 2;
@@ -230,9 +237,9 @@ var OneLobby = (() => {
230
237
  }
231
238
  };
232
239
  var Reader = class {
233
- constructor(buf) {
234
- this.buf = buf;
235
- this.dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
240
+ constructor(buf2) {
241
+ this.buf = buf2;
242
+ this.dv = new DataView(buf2.buffer, buf2.byteOffset, buf2.byteLength);
236
243
  }
237
244
  buf;
238
245
  dv;
@@ -380,6 +387,18 @@ var OneLobby = (() => {
380
387
  w.u32(msg.id);
381
388
  w.u8(msg.reason);
382
389
  break;
390
+ case "pause_request":
391
+ w.u8(msg.paused);
392
+ break;
393
+ case "pause_state":
394
+ w.u8(msg.paused);
395
+ w.u16(msg.byPeer);
396
+ w.u8(msg.flags);
397
+ break;
398
+ case "pause_refuse":
399
+ w.u8(msg.paused);
400
+ w.u8(msg.reason);
401
+ break;
383
402
  }
384
403
  const out = w.done();
385
404
  if (!allowOversize && out.length > MAX_FRAME_BYTES) throw new CodecError("too_long", `${out.length} > ${MAX_FRAME_BYTES}`);
@@ -388,11 +407,11 @@ var OneLobby = (() => {
388
407
  var TYPE_BY_ID = new Map(
389
408
  Object.keys(MsgType).map((k) => [MsgType[k], k])
390
409
  );
391
- function decodeMessage(buf, reassembled = false) {
410
+ function decodeMessage(buf2, reassembled = false) {
392
411
  const max = reassembled ? MAX_REASSEMBLED_BYTES : MAX_FRAME_BYTES;
393
- if (buf.length > max) throw new CodecError("too_long", `${buf.length} > ${max}`);
394
- if (buf.length < HEADER_BYTES) throw new CodecError("too_short", "header");
395
- const r = new Reader(buf);
412
+ if (buf2.length > max) throw new CodecError("too_long", `${buf2.length} > ${max}`);
413
+ if (buf2.length < HEADER_BYTES) throw new CodecError("too_short", "header");
414
+ const r = new Reader(buf2);
396
415
  const type = TYPE_BY_ID.get(r.u8());
397
416
  const route = r.u16();
398
417
  if (!type) return null;
@@ -475,14 +494,25 @@ var OneLobby = (() => {
475
494
  case "chat_reject":
476
495
  msg = { type, id: r.u32(), reason: r.u8() };
477
496
  break;
497
+ case "pause_request":
498
+ msg = { type, paused: r.u8() };
499
+ break;
500
+ case "pause_state":
501
+ msg = { type, paused: r.u8(), byPeer: r.u16(), flags: r.u8() };
502
+ break;
503
+ case "pause_refuse":
504
+ msg = { type, paused: r.u8(), reason: r.u8() };
505
+ break;
478
506
  }
479
- if (r.pos !== buf.length) throw new CodecError("trailing", `${buf.length - r.pos} extra bytes`);
507
+ if (r.pos !== buf2.length) throw new CodecError("trailing", `${buf2.length - r.pos} extra bytes`);
480
508
  return { route, msg };
481
509
  }
482
510
  var CHAT_REJECT = { rate: 1, length: 2, mutedByHost: 3 };
483
511
  function chatRejectName(code) {
484
512
  return code === 1 ? "rate" : code === 2 ? "length" : code === 3 ? "muted_by_host" : "unknown";
485
513
  }
514
+ var PAUSE_FLAG_PAUSER_LEFT = 1;
515
+ var PAUSE_REFUSE = { notInGame: 1, rateLimited: 2, notAllowed: 3 };
486
516
 
487
517
  // src/chat.ts
488
518
  var CHAT_MAX_BYTES = 256;
@@ -767,6 +797,7 @@ var OneLobby = (() => {
767
797
  cancelTicker = null;
768
798
  baseTime = 0;
769
799
  emitted = 0;
800
+ held = false;
770
801
  ownHashes = /* @__PURE__ */ new Map();
771
802
  peerHashes = /* @__PURE__ */ new Map();
772
803
  desyncs = /* @__PURE__ */ new Map();
@@ -872,6 +903,23 @@ var OneLobby = (() => {
872
903
  this._started = true;
873
904
  this.emit("started");
874
905
  }
906
+ /** The shared pause is on: the host emits no frames and a joiner expects none (no silent-host check). */
907
+ get isHeld() {
908
+ return this.held;
909
+ }
910
+ /**
911
+ * Pause (true) or resume (false) frame emission (the shared pause calls this; not for games). Resuming rebases the
912
+ * host's tick clock to now, so the next frame is simply tick+1 (no gap, no burst) and nobody counts as silent.
913
+ */
914
+ hold(held) {
915
+ if (this.held === held) return;
916
+ this.held = held;
917
+ if (held) return;
918
+ const now = this.hub.clock.now();
919
+ this.baseTime = now - this.emitted * (1e3 / this.hub.tickRate);
920
+ this.lastFrameRx = now;
921
+ for (const p of this.ready) this.lastHashRx.set(p, now);
922
+ }
875
923
  stop() {
876
924
  this.cancelTicker?.();
877
925
  this.cancelTicker = null;
@@ -887,7 +935,7 @@ var OneLobby = (() => {
887
935
  }
888
936
  // ---- host: frame emission ---------------------------------------------------------------------------------------
889
937
  pump(interval) {
890
- if (!this.hosting) return;
938
+ if (!this.hosting || this.held) return;
891
939
  const now = this.hub.clock.now();
892
940
  let due = Math.floor((now - this.baseTime + 1) / interval) - this.emitted;
893
941
  if (due > 10) {
@@ -1124,7 +1172,7 @@ var OneLobby = (() => {
1124
1172
  }
1125
1173
  /** True when a started joiner has heard no frames for 3 s: the Lobby then runs the host-loss rule (s.9). */
1126
1174
  hostSilent() {
1127
- return !this.hosting && this._started && this.msSinceFrame > HOST_SILENCE_MS;
1175
+ return !this.hosting && !this.held && this._started && this.msSinceFrame > HOST_SILENCE_MS;
1128
1176
  }
1129
1177
  /** Reset the silence timer, e.g. after the link to a new host opened. */
1130
1178
  touch() {
@@ -1290,6 +1338,138 @@ var OneLobby = (() => {
1290
1338
  return new OneLobbyError(String(m.code ?? "error"), m.msg === void 0 ? void 0 : String(m.msg), m.data);
1291
1339
  }
1292
1340
 
1341
+ // src/pause.ts
1342
+ var PAUSE_MIN_REQUEST_GAP_MS = 500;
1343
+ var NONE = Object.freeze({ paused: false, byPeer: 0, pauserLeft: false });
1344
+ function refusalName(code) {
1345
+ return code === PAUSE_REFUSE.notInGame ? "not_in_game" : code === PAUSE_REFUSE.rateLimited ? "rate_limited" : code === PAUSE_REFUSE.notAllowed ? "not_allowed" : "unknown";
1346
+ }
1347
+ var Pause = class extends Emitter {
1348
+ constructor(hub, deps) {
1349
+ super();
1350
+ this.hub = hub;
1351
+ this.deps = deps;
1352
+ }
1353
+ hub;
1354
+ deps;
1355
+ /** Set by a blueprint (`session.pause`); the default is "anyone". */
1356
+ mode = "anyone";
1357
+ /**
1358
+ * Host: replaces the default policy. Called with (requesting peer, true to pause / false to resume); return true to
1359
+ * allow. Default: anyone pauses, the pauser or the host resumes (host only in "host" mode). Phase and rate run first.
1360
+ */
1361
+ policy = null;
1362
+ current = NONE;
1363
+ lastAccepted = /* @__PURE__ */ new Map();
1364
+ /** The current shared state (the same on every peer once the host's broadcast has arrived). */
1365
+ get state() {
1366
+ return this.current;
1367
+ }
1368
+ /**
1369
+ * True when the local peer may resume now under the default policy: the pauser or the host (in "host" mode, the
1370
+ * host). A custom `policy` lives on the host, so on a joiner this is a hint.
1371
+ */
1372
+ get canResume() {
1373
+ return this.current.paused && this.defaultAllows(this.hub.localPeerId, false);
1374
+ }
1375
+ /**
1376
+ * Ask to pause (true) or resume (false) the match for everyone. Throws `pause_disabled` when the blueprint says
1377
+ * `session.pause` is "off" (the default; set it to "anyone" or "host" in onelobby.json). Works only while the lobby
1378
+ * is in game (phase "in_game", after `start()`): before that the host refuses with `not_in_game`. The result arrives
1379
+ * as `changed` or `refused`; asking for the state that already holds does nothing.
1380
+ */
1381
+ request(paused) {
1382
+ if (this.mode === "off") throw new OneLobbyError("pause_disabled", 'pause is off in onelobby.json (session.pause is "off"); set session.pause to "anyone" or "host" to turn it on');
1383
+ if (this.hub.isHost) this.hostHandle(this.hub.localPeerId, paused);
1384
+ else this.hub.sendToHost({ type: "pause_request", paused: paused ? 1 : 0 });
1385
+ }
1386
+ /**
1387
+ * Host only: unpause at once, skipping the policy and the rate limit. Call it before a restart or game over so the
1388
+ * next round does not start frozen. Does nothing when not paused.
1389
+ */
1390
+ clear() {
1391
+ if (!this.hub.isHost) throw new Error("only the host can clear the pause");
1392
+ if (!this.current.paused) return;
1393
+ this.set({ paused: false, byPeer: this.hub.localPeerId, pauserLeft: false }, true);
1394
+ }
1395
+ // ---- internals (called by Lobby) ------------------------------------------------------------------------------
1396
+ handle(from, msg) {
1397
+ switch (msg.type) {
1398
+ case "pause_request":
1399
+ if (this.hub.isHost && this.hub.connectedPeers().includes(from)) this.hostHandle(from, msg.paused !== 0);
1400
+ break;
1401
+ case "pause_state":
1402
+ if (!this.hub.isHost && from === this.hub.hostPeerId) {
1403
+ this.set({ paused: msg.paused !== 0, byPeer: msg.byPeer, pauserLeft: (msg.flags & PAUSE_FLAG_PAUSER_LEFT) !== 0 }, false);
1404
+ }
1405
+ break;
1406
+ case "pause_refuse":
1407
+ if (!this.hub.isHost && from === this.hub.hostPeerId) this.emit("refused", refusalName(msg.reason));
1408
+ break;
1409
+ }
1410
+ }
1411
+ /** Host: a late joiner's link is up; it gets the state when there is one to tell. */
1412
+ sendState(peerId) {
1413
+ if (this.current.paused) this.hub.sendTo(peerId, this.stateMessage(), 0);
1414
+ }
1415
+ /** A peer is gone: the host takes over the pause its leaver held. */
1416
+ onPeerLeft(peerId) {
1417
+ this.lastAccepted.delete(peerId);
1418
+ const s = this.current;
1419
+ if (this.hub.isHost && s.paused && !s.pauserLeft && s.byPeer === peerId) {
1420
+ this.set({ paused: true, byPeer: this.hub.localPeerId, pauserLeft: true }, true);
1421
+ }
1422
+ }
1423
+ /** This peer just became the host (migration): it keeps the state as is, and takes over a pause whose holder is gone. */
1424
+ onBecameHost() {
1425
+ this.deps.hold(this.current.paused);
1426
+ const s = this.current;
1427
+ if (s.paused && !s.pauserLeft && !this.deps.hasPeer(s.byPeer)) {
1428
+ this.set({ paused: true, byPeer: this.hub.localPeerId, pauserLeft: true }, true);
1429
+ }
1430
+ }
1431
+ /** The lobby went back to the lobby phase: nothing stays paused. */
1432
+ onLobbyPhase() {
1433
+ if (!this.current.paused) return;
1434
+ this.set({ paused: false, byPeer: this.hub.hostPeerId, pauserLeft: false }, this.hub.isHost);
1435
+ }
1436
+ // ---- host path --------------------------------------------------------------------------------------------------
1437
+ defaultAllows(peer, wantPaused) {
1438
+ if (this.mode === "host") return peer === this.hub.hostPeerId;
1439
+ return wantPaused || peer === this.current.byPeer || peer === this.hub.hostPeerId;
1440
+ }
1441
+ hostHandle(from, want) {
1442
+ if (this.mode === "off") return this.refuse(from, want, PAUSE_REFUSE.notAllowed);
1443
+ if (this.deps.phase() !== "in_game") return this.refuse(from, want, PAUSE_REFUSE.notInGame);
1444
+ if (want === this.current.paused) return;
1445
+ const now = this.hub.clock.now();
1446
+ const last = this.lastAccepted.get(from);
1447
+ if (last !== void 0 && now - last < PAUSE_MIN_REQUEST_GAP_MS) return this.refuse(from, want, PAUSE_REFUSE.rateLimited);
1448
+ const ok = this.policy ? this.policy(from, want) : this.defaultAllows(from, want);
1449
+ if (!ok) return this.refuse(from, want, PAUSE_REFUSE.notAllowed);
1450
+ this.lastAccepted.set(from, now);
1451
+ this.set({ paused: want, byPeer: from, pauserLeft: false }, true);
1452
+ }
1453
+ refuse(to, want, reason) {
1454
+ if (to === this.hub.localPeerId) this.emit("refused", refusalName(reason));
1455
+ else this.hub.sendTo(to, { type: "pause_refuse", paused: want ? 1 : 0, reason }, 0);
1456
+ }
1457
+ stateMessage() {
1458
+ const s = this.current;
1459
+ return { type: "pause_state", paused: s.paused ? 1 : 0, byPeer: s.byPeer, flags: s.pauserLeft ? PAUSE_FLAG_PAUSER_LEFT : 0 };
1460
+ }
1461
+ set(next, broadcast) {
1462
+ const prev = this.current;
1463
+ this.current = Object.freeze(next);
1464
+ this.deps.hold(next.paused);
1465
+ if (broadcast) {
1466
+ const msg = this.stateMessage();
1467
+ for (const p of this.hub.connectedPeers()) this.hub.sendTo(p, msg, 0);
1468
+ }
1469
+ if (prev.paused !== next.paused || prev.byPeer !== next.byPeer || prev.pauserLeft !== next.pauserLeft) this.emit("changed", next);
1470
+ }
1471
+ };
1472
+
1293
1473
  // src/voice-core.ts
1294
1474
  var SAMPLE_RATE = 48e3;
1295
1475
  var FRAME_MS = 20;
@@ -1810,6 +1990,7 @@ var OneLobby = (() => {
1810
1990
  // src/lobby.ts
1811
1991
  var MIN_PEER_TIMEOUT_MS = 6e3;
1812
1992
  var KICK_LINGER_MS = 3e3;
1993
+ var HELD_OFFER_MS = 2e4;
1813
1994
  function seatName(p) {
1814
1995
  const n = typeof p.name === "string" ? p.name.trim() : "";
1815
1996
  if (n && n !== "Player") return p;
@@ -1826,31 +2007,61 @@ var OneLobby = (() => {
1826
2007
  tickRate;
1827
2008
  lockstep;
1828
2009
  chat;
2010
+ /** The shared co-op pause (docs/recipes/pause.md). */
2011
+ pause;
1829
2012
  voice;
1830
2013
  localPeerId;
1831
2014
  hostPeerId;
1832
2015
  phase;
1833
2016
  meta;
2017
+ /** The lobby's name as the server keeps it (host()'s `name`, sanitised); "" if the server sends none. */
2018
+ name;
1834
2019
  resumeToken;
1835
2020
  /** Last measured round trip to the host in ms (joiners), or 0. */
1836
2021
  rtt = 0;
1837
2022
  /**
1838
- * Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/direct-ip.md
2023
+ * Direct-IP host only (onelobby/direct): the code friends paste to join, e.g. "6B01-RGFE-P2B" (docs/design/direct-ip.md
1839
2024
  * s.2a). Show it with joinCodeKind. Empty when the machine has no IPv4 address, and on joiners.
1840
2025
  */
1841
2026
  joinCode = "";
1842
- /** "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN address. */
2027
+ /**
2028
+ * "nostr": a version 1 code that works through public Nostr relays (spec/nostr-signaling.md; the default for a direct
2029
+ * host). "internet": the router forwarded the port and the code carries its public address; "same_network": the LAN
2030
+ * address. "none": no code.
2031
+ */
1843
2032
  joinCodeKind = "none";
2033
+ /**
2034
+ * Direct-IP host only: the IP code (address and port, version 0), with `directCodeKind` "internet" or "same_network".
2035
+ * Equal to `joinCode` when Nostr is off; otherwise the code to hand out for LAN play or when the router port is open.
2036
+ */
2037
+ directCode = "";
2038
+ directCodeKind = "none";
1844
2039
  /** Direct-IP host only: this machine's addresses with the port, for an "Advanced" fold-out. */
1845
2040
  directAddresses = [];
1846
2041
  /** Direct-IP host only: how the router port-open went ("UPnP", "no UPnP gateway answered; ...", "off"). */
1847
2042
  portMapping = "";
2043
+ /** Direct-IP host only: the one startup line (bound port, join code, port mapping); see `formatHostLog` in onelobby/direct. */
2044
+ directHostLog = "";
2045
+ /** Browser host only (`kit.hostNostr`): true while the host tab is hidden (see the `hostHiddenChanged` event). */
2046
+ hostHidden = false;
2047
+ /** @internal kit.hostNostr: the tab's visibility changed. */
2048
+ setHostHidden(hidden) {
2049
+ if (this.hostHidden === hidden) return;
2050
+ this.hostHidden = hidden;
2051
+ this.emit("hostHiddenChanged", hidden);
2052
+ }
2053
+ /** @internal onelobby/direct: fires `joinCodeChanged` with the current code. */
2054
+ notifyJoinCodeChanged() {
2055
+ this.emit("joinCodeChanged", this.joinCode, this.joinCodeKind);
2056
+ }
1848
2057
  signaling;
1849
2058
  cfg;
1850
2059
  roster = /* @__PURE__ */ new Map();
1851
2060
  links = /* @__PURE__ */ new Map();
1852
2061
  /** ICE from a peer that has no link yet: its candidates can overtake its offer (node-datachannel). */
1853
2062
  earlyIce = /* @__PURE__ */ new Map();
2063
+ /** Offers from roster members that arrived while the host link was open (see offerVerdict), by sender. */
2064
+ heldOffers = /* @__PURE__ */ new Map();
1854
2065
  connected = /* @__PURE__ */ new Set();
1855
2066
  // host: peers that said hi
1856
2067
  hiSentTo = /* @__PURE__ */ new Set();
@@ -1875,6 +2086,8 @@ var OneLobby = (() => {
1875
2086
  lastHeard = /* @__PURE__ */ new Map();
1876
2087
  /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
1877
2088
  lingering = /* @__PURE__ */ new Set();
2089
+ /** Host: joiners that joined but are not ready yet, with the cancel of their readyTimeoutMs timer (peerFailed). */
2090
+ notReady = /* @__PURE__ */ new Map();
1878
2091
  /** Host: per-peer fixed window for rateLimit. */
1879
2092
  rate = /* @__PURE__ */ new Map();
1880
2093
  /**
@@ -1904,6 +2117,7 @@ var OneLobby = (() => {
1904
2117
  this.hostPeerId = st.hostId;
1905
2118
  this.phase = st.phase;
1906
2119
  this.meta = { ...st.meta };
2120
+ this.name = st.name ?? "";
1907
2121
  this.resumeToken = st.resumeToken;
1908
2122
  for (const p of st.peers) this.roster.set(p.peerId, seatName(p));
1909
2123
  this.ready.catch(() => {
@@ -1937,6 +2151,11 @@ var OneLobby = (() => {
1937
2151
  this.lockstep = new Lockstep(this.hub, { hashEveryTicks: cfg.hashEveryTicks });
1938
2152
  this.chat = new Chat(this.hub, cfg.wall, (id) => this.voice.isMutedPeer(id));
1939
2153
  this.voice = new Voice(this.hub, cfg.voice);
2154
+ this.pause = new Pause(this.hub, {
2155
+ phase: () => this.phase,
2156
+ hold: (held) => this.lockstep.hold(held),
2157
+ hasPeer: (id) => this.roster.has(id)
2158
+ });
1940
2159
  this.lockstep.on("started", () => this.setPhase("in_game"));
1941
2160
  this.lockstep.on("error", (e) => this.reportError(e));
1942
2161
  this.signaling.onMessage = (m) => this.onSignaling(m);
@@ -1964,14 +2183,9 @@ var OneLobby = (() => {
1964
2183
  get connectedPeers() {
1965
2184
  return this.connectedPeerIds();
1966
2185
  }
1967
- /**
1968
- * Joiner: the link to the host as a WebSocket-shaped object (text by default), so WebSocket client code runs
1969
- * unchanged. It opens when `ready` resolves and closes when the session ends (`host_left` gives code 1001).
1970
- */
1971
2186
  asWebSocket(opts) {
1972
2187
  return joinerSocket(this, opts);
1973
2188
  }
1974
- /** Host: `handler(conn)` once per joiner (a WebSocket-shaped connection), the server-side mirror of asWebSocket(). */
1975
2189
  onConnection(handler, opts) {
1976
2190
  return hostConnections(this, handler, opts);
1977
2191
  }
@@ -2002,6 +2216,16 @@ var OneLobby = (() => {
2002
2216
  this.setPhase("in_game");
2003
2217
  if (this.mode === "lockstep") this.lockstep.hostStart();
2004
2218
  }
2219
+ /**
2220
+ * Changes your display name mid-session. The hub sanitises it (as at hello, at most 24 characters) and every member,
2221
+ * you included, gets `peerRenamed`. Names need not be unique. At most one rename per second; a refusal
2222
+ * (`bad_request` for an empty name, `rate_limited`) arrives on the kit's `error` event. Throws `unsupported` when
2223
+ * the hub is too old to rename.
2224
+ */
2225
+ async rename(name) {
2226
+ if (!(this.cfg.features ?? []).includes("set_name")) throw new OneLobbyError("unsupported", "this lobby's hub cannot rename players (update it)");
2227
+ this.signaling.send({ t: "set_name", name: String(name ?? "") });
2228
+ }
2005
2229
  async setMeta(meta) {
2006
2230
  if (!this.isHost) throw new Error("only the host can set metadata");
2007
2231
  this.meta = { ...meta };
@@ -2133,7 +2357,9 @@ var OneLobby = (() => {
2133
2357
  }, budget);
2134
2358
  }
2135
2359
  giveUpConnecting() {
2136
- const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`);
2360
+ const hostLink = this.links.get(this.hostPeerId);
2361
+ const diagnostics = hostLink ? hostLink.diagnostics?.() : this.lastHostLinkEnd ?? "no offer from the host ever arrived";
2362
+ const err = new OneLobbyError("ice_failed", `could not open a connection to the host${this.relayRetry ? " (direct or relayed)" : ""}`, diagnostics ? { diagnostics } : void 0);
2137
2363
  this.failReady(err);
2138
2364
  this.reportError(err);
2139
2365
  if (this.ended) return;
@@ -2141,6 +2367,8 @@ var OneLobby = (() => {
2141
2367
  this.teardown("ice_failed");
2142
2368
  }
2143
2369
  // ---- links -----------------------------------------------------------------------------------------------------
2370
+ /** @internal A direct host's hub learns where each joiner's link comes from (Nostr kick bans the address too). */
2371
+ linkAddressSink = null;
2144
2372
  makeLink(peerId, relayOnly = this.cfg.relayOnly) {
2145
2373
  const old = this.links.get(peerId);
2146
2374
  if (old) {
@@ -2155,6 +2383,7 @@ var OneLobby = (() => {
2155
2383
  link.onClose = (reason) => this.onLinkClosed(peerId, link, reason);
2156
2384
  link.onMessage = (ch, bytes) => this.onData(peerId, ch, bytes);
2157
2385
  link.onPath = (path) => this.signaling.send({ t: "path", path });
2386
+ link.onRemoteAddress = (ip) => this.linkAddressSink?.(peerId, ip);
2158
2387
  return link;
2159
2388
  }
2160
2389
  /** Host side: offer to a joiner. */
@@ -2179,8 +2408,11 @@ var OneLobby = (() => {
2179
2408
  this.emit("peerReady", this.roster.get(link.remoteId) ?? { peerId: link.remoteId, name: "" });
2180
2409
  this.flush(link.remoteId);
2181
2410
  }
2411
+ /** Why and how the joiner's last link to the host ended (shown when the join gives up). */
2412
+ lastHostLinkEnd = null;
2182
2413
  onLinkClosed(peerId, link, _reason) {
2183
2414
  if (this.links.get(peerId) !== link) return;
2415
+ if (!this.isHost && peerId === this.hostPeerId) this.lastHostLinkEnd = `link ended (${_reason}): ${link.diagnostics?.() ?? ""}`;
2184
2416
  this.links.delete(peerId);
2185
2417
  this.hiSentTo.delete(link);
2186
2418
  if (this.ended) return;
@@ -2204,6 +2436,7 @@ var OneLobby = (() => {
2204
2436
  return;
2205
2437
  }
2206
2438
  if (!d) return;
2439
+ if (!this.isHost && from !== this.hostPeerId && d.msg.type !== "host_claim") return;
2207
2440
  if (!reassembled) this.lastHeard.set(from, this.cfg.clock.now());
2208
2441
  const { msg, route } = d;
2209
2442
  switch (msg.type) {
@@ -2245,6 +2478,11 @@ var OneLobby = (() => {
2245
2478
  case "chat_reject":
2246
2479
  this.chat.handle(from, route, msg);
2247
2480
  break;
2481
+ case "pause_request":
2482
+ case "pause_state":
2483
+ case "pause_refuse":
2484
+ this.pause.handle(from, msg);
2485
+ break;
2248
2486
  case "voice":
2249
2487
  if (channel === "voice") this.voice.handle(from, route, msg);
2250
2488
  break;
@@ -2264,6 +2502,8 @@ var OneLobby = (() => {
2264
2502
  }
2265
2503
  onHi(from, lastTick) {
2266
2504
  this.connected.add(from);
2505
+ this.notReady.get(from)?.();
2506
+ this.notReady.delete(from);
2267
2507
  this.sendTo(from, {
2268
2508
  type: "welcome_dc",
2269
2509
  // the tick the snapshot will carry (the game's applied tick), so welcome_dc.tick == snapshot.tick even when
@@ -2274,6 +2514,7 @@ var OneLobby = (() => {
2274
2514
  hostId: this.localPeerId
2275
2515
  }, 0);
2276
2516
  this.chat.sendHistory(from);
2517
+ this.pause.sendState(from);
2277
2518
  if (this.mode === "lockstep") this.lockstep.onPeerHi(from, lastTick);
2278
2519
  this.emit("peerReady", this.roster.get(from) ?? { peerId: from, name: "" });
2279
2520
  this.flush(from);
@@ -2312,6 +2553,11 @@ var OneLobby = (() => {
2312
2553
  if (hostId === this.hostPeerId) return;
2313
2554
  this.hostPeerId = hostId;
2314
2555
  this.emit("hostChanged", hostId);
2556
+ const held = this.heldOffers.get(hostId);
2557
+ if (held) {
2558
+ this.heldOffers.delete(hostId);
2559
+ if (this.cfg.clock.now() - held.at <= HELD_OFFER_MS) void this.acceptOffer(hostId, held.payload);
2560
+ }
2315
2561
  }
2316
2562
  /** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
2317
2563
  handleHostLost(reason) {
@@ -2336,6 +2582,15 @@ var OneLobby = (() => {
2336
2582
  }
2337
2583
  const candidate = pickNewHost([...this.roster.keys(), this.localPeerId], failed) ?? this.localPeerId;
2338
2584
  if (candidate === this.localPeerId) this.becomeHost();
2585
+ this.replayHeldOffers();
2586
+ }
2587
+ /** Offers that came from roster members while the host link was still open: now the host is lost, answer them. */
2588
+ replayHeldOffers() {
2589
+ const now = this.cfg.clock.now();
2590
+ for (const [from, held] of [...this.heldOffers]) {
2591
+ this.heldOffers.delete(from);
2592
+ if (now - held.at <= HELD_OFFER_MS && this.roster.has(from)) void this.acceptOffer(from, held.payload);
2593
+ }
2339
2594
  }
2340
2595
  becomeHost() {
2341
2596
  this.epoch = this.epoch + 1 & 65535;
@@ -2345,6 +2600,7 @@ var OneLobby = (() => {
2345
2600
  this.hiSentTo.clear();
2346
2601
  this.lastHeard.clear();
2347
2602
  this.lockstep.becomeHost();
2603
+ this.pause.onBecameHost();
2348
2604
  this.emit("hostChanged", this.localPeerId);
2349
2605
  if (this.serverUp) this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
2350
2606
  for (const p of this.roster.keys()) {
@@ -2352,6 +2608,7 @@ var OneLobby = (() => {
2352
2608
  }
2353
2609
  }
2354
2610
  onHostClaim(from, epoch, hostId) {
2611
+ if (hostId !== from) return;
2355
2612
  const mine = { epoch: this.epoch, hostId: this.hostPeerId };
2356
2613
  const theirs = { epoch, hostId };
2357
2614
  if (hostId === this.hostPeerId && epoch === this.epoch && !this.isHost) {
@@ -2385,7 +2642,10 @@ var OneLobby = (() => {
2385
2642
  const peer = seatName(m.peer);
2386
2643
  this.roster.set(peer.peerId, peer);
2387
2644
  this.emit("peerJoined", peer);
2388
- if (this.isHost) this.offerTo(peer.peerId);
2645
+ if (this.isHost) {
2646
+ this.watchReady(peer.peerId);
2647
+ this.offerTo(peer.peerId);
2648
+ }
2389
2649
  break;
2390
2650
  }
2391
2651
  case "peer_left":
@@ -2414,11 +2674,17 @@ var OneLobby = (() => {
2414
2674
  case "lobby": {
2415
2675
  const st = m;
2416
2676
  this.meta = { ...st.meta };
2677
+ if (st.name) this.name = st.name;
2417
2678
  if (st.resumeToken && st.resumeToken !== this.resumeToken) {
2418
2679
  this.resumeToken = st.resumeToken;
2419
2680
  this.seatSink?.(this.resumeToken);
2420
2681
  }
2682
+ const before = this.roster;
2421
2683
  this.roster = new Map(st.peers.map((p) => [p.peerId, seatName(p)]));
2684
+ for (const [id, p] of this.roster) {
2685
+ const old = before.get(id);
2686
+ if (old && old.name !== p.name) this.emit("peerRenamed", p, old.name);
2687
+ }
2422
2688
  if (st.hostId !== this.hostPeerId && !this.isHost) this.setHost(st.hostId);
2423
2689
  this.setPhase(st.phase);
2424
2690
  break;
@@ -2450,16 +2716,15 @@ var OneLobby = (() => {
2450
2716
  if (s.sdp) payload.sdp = s.sdp;
2451
2717
  if (s.ice) payload.ice = s.ice;
2452
2718
  if (s.sdp?.type === "offer") {
2453
- link = this.makeLink(from);
2454
- const early = this.earlyIce.get(from) ?? [];
2455
- this.earlyIce.delete(from);
2456
- const done = link.handleSignal(payload);
2457
- for (const e of early) void link.handleSignal(e);
2458
- await done;
2719
+ const verdict = this.offerVerdict(from);
2720
+ if (verdict === "hold") {
2721
+ if (this.heldOffers.size < 64 || this.heldOffers.has(from)) this.heldOffers.set(from, { payload, at: this.cfg.clock.now() });
2722
+ } else if (verdict === "accept") await this.acceptOffer(from, payload);
2459
2723
  return;
2460
2724
  }
2461
2725
  if (!link) {
2462
2726
  if (!payload.ice || this.ended || !this.roster.has(from)) return;
2727
+ if (this.cfg.direct && from !== this.hostPeerId) return;
2463
2728
  const q = this.earlyIce.get(from) ?? [];
2464
2729
  if (q.length < 64) q.push(payload);
2465
2730
  this.earlyIce.set(from, q);
@@ -2467,6 +2732,28 @@ var OneLobby = (() => {
2467
2732
  }
2468
2733
  await link.handleSignal(payload);
2469
2734
  }
2735
+ /**
2736
+ * Whether an SDP offer from `from` may be answered. The host (or a joiner's host) always may. Direct lobbies have no
2737
+ * migration, so nobody else. In a server lobby a roster member may dial us when we are the host (a claim race decides
2738
+ * who wins) or when our own link to the host is gone (the migration window); while that link is open the offer is
2739
+ * held, to be answered if the host turns out to be lost a moment later.
2740
+ */
2741
+ offerVerdict(from) {
2742
+ if (this.ended || from === this.localPeerId) return "drop";
2743
+ if (!this.isHost && from === this.hostPeerId) return "accept";
2744
+ if (this.cfg.direct || !this.roster.has(from)) return "drop";
2745
+ if (this.isHost) return "accept";
2746
+ if (!this.isReady) return "drop";
2747
+ return this.links.get(this.hostPeerId)?.isOpen ? "hold" : "accept";
2748
+ }
2749
+ async acceptOffer(from, payload) {
2750
+ const link = this.makeLink(from);
2751
+ const early = this.earlyIce.get(from) ?? [];
2752
+ this.earlyIce.delete(from);
2753
+ const done = link.handleSignal(payload);
2754
+ for (const e of early) void link.handleSignal(e);
2755
+ await done;
2756
+ }
2470
2757
  /**
2471
2758
  * `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
2472
2759
  * or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
@@ -2480,6 +2767,12 @@ var OneLobby = (() => {
2480
2767
  }
2481
2768
  if (peer) this.roster.delete(peerId);
2482
2769
  this.earlyIce.delete(peerId);
2770
+ const cancelWatch = this.notReady.get(peerId);
2771
+ if (cancelWatch) {
2772
+ cancelWatch();
2773
+ this.notReady.delete(peerId);
2774
+ if (peer && reason !== "kicked" && reason !== "disconnected") this.emit("peerFailed", peer, reason);
2775
+ }
2483
2776
  const link = this.links.get(peerId);
2484
2777
  if (link && peerId !== this.hostPeerId) {
2485
2778
  link.onMessage = null;
@@ -2507,6 +2800,7 @@ var OneLobby = (() => {
2507
2800
  this.lockstep.onPeerLeft(peerId);
2508
2801
  this.chat.onPeerLeft(peerId);
2509
2802
  this.voice.onPeerLeft(peerId);
2803
+ this.pause.onPeerLeft(peerId);
2510
2804
  if (peer) this.emit("peerLeft", peer, reason, close);
2511
2805
  if (peerId === this.hostPeerId && !this.isHost) {
2512
2806
  this.handleHostLost(reason);
@@ -2521,6 +2815,7 @@ var OneLobby = (() => {
2521
2815
  if (p !== "lobby" && p !== "in_game") return;
2522
2816
  if (this.phase === p) return;
2523
2817
  this.phase = p;
2818
+ if (p === "lobby") this.pause.onLobbyPhase();
2524
2819
  if (p === "in_game" && !this.lockstep.started) this.lockstep.markStarted();
2525
2820
  this.emit("phase", p);
2526
2821
  }
@@ -2566,10 +2861,26 @@ var OneLobby = (() => {
2566
2861
  if (this.ended || this.isHost) return;
2567
2862
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
2568
2863
  }
2864
+ /** Host: report a joiner that is not ready `readyTimeoutMs` after joining as peerFailed("timeout"), once. */
2865
+ watchReady(peerId) {
2866
+ const ms = this.cfg.readyTimeoutMs ?? 0;
2867
+ let cancel = () => {
2868
+ };
2869
+ if (ms > 0) {
2870
+ cancel = this.cfg.clock.setTimeout(() => {
2871
+ if (!this.notReady.delete(peerId)) return;
2872
+ const peer = this.roster.get(peerId);
2873
+ if (peer) this.emit("peerFailed", peer, "timeout");
2874
+ }, ms);
2875
+ }
2876
+ this.notReady.set(peerId, cancel);
2877
+ }
2569
2878
  teardown(reason, close) {
2570
2879
  if (this.ended) return;
2571
2880
  this.ended = true;
2572
2881
  this.cancelReadyTimer?.();
2882
+ for (const cancel of this.notReady.values()) cancel();
2883
+ this.notReady.clear();
2573
2884
  if (!this.isReady) {
2574
2885
  const code = reason === "ice_failed" || reason === "kicked" ? reason : "closed";
2575
2886
  this.failReady(new OneLobbyError(code, `the lobby ended before connecting (${reason})`));
@@ -2585,6 +2896,7 @@ var OneLobby = (() => {
2585
2896
  }
2586
2897
  this.links.clear();
2587
2898
  this.earlyIce.clear();
2899
+ this.heldOffers.clear();
2588
2900
  this.lingering.clear();
2589
2901
  this.signaling.onClose = null;
2590
2902
  this.signaling.onMessage = null;
@@ -2608,7 +2920,7 @@ var OneLobby = (() => {
2608
2920
  var webCodecsOpus = {
2609
2921
  createEncoder(opts, onFrame) {
2610
2922
  let ts = 0;
2611
- const enc4 = new AudioEncoder({
2923
+ const enc5 = new AudioEncoder({
2612
2924
  output: (chunk) => {
2613
2925
  const out = new Uint8Array(chunk.byteLength);
2614
2926
  chunk.copyTo(out);
@@ -2616,7 +2928,7 @@ var OneLobby = (() => {
2616
2928
  },
2617
2929
  error: (e) => console.error("onelobby: AudioEncoder", e)
2618
2930
  });
2619
- enc4.configure({
2931
+ enc5.configure({
2620
2932
  codec: "opus",
2621
2933
  sampleRate: SAMPLE_RATE,
2622
2934
  numberOfChannels: 1,
@@ -2634,11 +2946,11 @@ var OneLobby = (() => {
2634
2946
  data: pcm
2635
2947
  });
2636
2948
  ts += 2e4;
2637
- enc4.encode(data);
2949
+ enc5.encode(data);
2638
2950
  data.close();
2639
2951
  },
2640
2952
  close() {
2641
- if (enc4.state !== "closed") enc4.close();
2953
+ if (enc5.state !== "closed") enc5.close();
2642
2954
  }
2643
2955
  };
2644
2956
  },
@@ -2723,10 +3035,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2723
3035
  }
2724
3036
  play(peerId, pcm, gain) {
2725
3037
  if (pcm.length === 0) return;
2726
- const buf = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
2727
- buf.copyToChannel(pcm, 0);
3038
+ const buf2 = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
3039
+ buf2.copyToChannel(pcm, 0);
2728
3040
  const src = this.ctx.createBufferSource();
2729
- src.buffer = buf;
3041
+ src.buffer = buf2;
2730
3042
  const g = this.ctx.createGain();
2731
3043
  g.gain.value = gain;
2732
3044
  src.connect(g).connect(this.ctx.destination);
@@ -2755,6 +3067,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2755
3067
  onClose = null;
2756
3068
  onMessage = null;
2757
3069
  onPath = null;
3070
+ onRemoteAddress = null;
2758
3071
  pc;
2759
3072
  channels = {};
2760
3073
  pendingIce = [];
@@ -2767,6 +3080,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2767
3080
  opened = 0;
2768
3081
  early = [];
2769
3082
  extra;
3083
+ born = Date.now();
3084
+ trace = [];
2770
3085
  /** `rtc`: the RTCPeerConnection class to use (default the global one; in Node, node-datachannel's polyfill). */
2771
3086
  constructor(opts, rtc = globalThis.RTCPeerConnection) {
2772
3087
  this.remoteId = opts.remoteId;
@@ -2782,16 +3097,28 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2782
3097
  }
2783
3098
  this.pc = new rtc(config);
2784
3099
  this.pc.onicecandidate = (e) => {
2785
- if (e.candidate) this.onSignal?.({ ice: e.candidate.toJSON() });
3100
+ if (e.candidate) {
3101
+ this.note(`out ice ${e.candidate.candidate}`);
3102
+ this.onSignal?.({ ice: e.candidate.toJSON() });
3103
+ }
2786
3104
  };
2787
3105
  this.pc.onconnectionstatechange = () => {
2788
3106
  const s = this.pc.connectionState;
3107
+ this.note(`connection ${s}`);
2789
3108
  if (s === "failed" || s === "closed" || s === "disconnected") {
2790
3109
  if (s === "disconnected") setTimeout(() => this.pc.connectionState === "disconnected" && this.fail("disconnected"), 4e3);
2791
3110
  else this.fail(s);
2792
3111
  }
2793
3112
  };
2794
3113
  }
3114
+ note(what) {
3115
+ if (this.trace.length < 40) this.trace.push(`+${Date.now() - this.born}ms ${what}`);
3116
+ }
3117
+ /** What a "could not connect" report adds: the peer connection's states and the signals seen so far. */
3118
+ diagnostics() {
3119
+ const pc = this.pc;
3120
+ return `connection=${pc.connectionState} ice=${pc.iceConnectionState} gathering=${pc.iceGatheringState} signaling=${pc.signalingState} haveRemote=${this.haveRemote} pendingIce=${this.pendingIce.length}; ${this.trace.join("; ")}`;
3121
+ }
2795
3122
  /**
2796
3123
  * The offerer creates the channels just before its offer, the answerer once the remote offer is set. Creating them
2797
3124
  * any earlier makes libdatachannel (node-datachannel) start an offer of its own on the answering side, which then
@@ -2810,7 +3137,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2810
3137
  void this.reportPath();
2811
3138
  }
2812
3139
  };
2813
- ch.onclose = () => this.fail(`channel ${name} closed`);
3140
+ if (name === "reliable") ch.onclose = () => this.fail(`channel ${name} closed`);
2814
3141
  ch.onmessage = (e) => {
2815
3142
  const d = e.data;
2816
3143
  const data = d instanceof ArrayBuffer ? new Uint8Array(d) : ArrayBuffer.isView(d) ? new Uint8Array(d.buffer, d.byteOffset, d.byteLength) : new Uint8Array(d);
@@ -2829,6 +3156,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2829
3156
  this.openChannels();
2830
3157
  const offer = await this.pc.createOffer();
2831
3158
  await this.pc.setLocalDescription(offer);
3159
+ this.note("out offer");
2832
3160
  this.onSignal?.({ sdp: { type: "offer", sdp: offer.sdp ?? "" } });
2833
3161
  } catch (e) {
2834
3162
  this.fail(`offer failed: ${String(e)}`);
@@ -2836,6 +3164,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2836
3164
  })();
2837
3165
  }
2838
3166
  async handleSignal(s) {
3167
+ this.note(`in ${s.sdp ? s.sdp.type : `ice ${s.ice?.candidate}`}`);
2839
3168
  try {
2840
3169
  if (s.sdp) {
2841
3170
  await this.pc.setRemoteDescription({ type: s.sdp.type, sdp: s.sdp.sdp });
@@ -2849,6 +3178,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2849
3178
  this.openChannels();
2850
3179
  const answer = await this.pc.createAnswer();
2851
3180
  await this.pc.setLocalDescription(answer);
3181
+ this.note("out answer");
2852
3182
  this.onSignal?.({ sdp: { type: "answer", sdp: answer.sdp ?? "" } });
2853
3183
  }
2854
3184
  }
@@ -2877,6 +3207,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2877
3207
  const l = pair && byId.get(pair.localCandidateId);
2878
3208
  const r = pair && byId.get(pair.remoteCandidateId);
2879
3209
  if (l && r) {
3210
+ const addr = String(r.address ?? r.ip ?? "");
3211
+ if (r.candidateType !== "relay" && l.candidateType !== "relay" && addr && !addr.endsWith(".local")) this.onRemoteAddress?.(addr.replace(/^::ffff:/i, ""));
2880
3212
  this.onPath?.(l.candidateType === "relay" || r.candidateType === "relay" ? "relayed" : "direct");
2881
3213
  return;
2882
3214
  }
@@ -2935,7 +3267,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2935
3267
  const ip = parseIPv4(ipv4);
2936
3268
  if (ip === null) throw new Error(`not an IPv4 address: ${ipv4}`);
2937
3269
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`bad port ${port}`);
2938
- const data = BigInt(ip) << 16n | BigInt(port);
3270
+ return encodeData(BigInt(ip) << 16n | BigInt(port));
3271
+ }
3272
+ function encodeData(data) {
2939
3273
  let v = data << 5n | BigInt(crc5(data, 50));
2940
3274
  let s = "";
2941
3275
  for (let i = 0; i < JOIN_CODE_LENGTH; i++) {
@@ -2944,6 +3278,15 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2944
3278
  }
2945
3279
  return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
2946
3280
  }
3281
+ function encodeRoomCodeFor(roomHex) {
3282
+ if (!/^[0-9a-fA-F]{12}$/.test(roomHex)) throw new Error(`room must be 12 hex digits: ${roomHex}`);
3283
+ return encodeData(1n << 48n | BigInt("0x" + roomHex));
3284
+ }
3285
+ function encodeRoomCode() {
3286
+ const b = new Uint8Array(6);
3287
+ globalThis.crypto.getRandomValues(b);
3288
+ return encodeRoomCodeFor([...b].map((x) => x.toString(16).padStart(2, "0")).join(""));
3289
+ }
2947
3290
  function normalizeJoinCode(input) {
2948
3291
  const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
2949
3292
  if (s.length !== JOIN_CODE_LENGTH) return null;
@@ -2957,7 +3300,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2957
3300
  for (const c of s) v = v << 5n | BigInt(CROCKFORD_ALPHABET.indexOf(c));
2958
3301
  const data = v >> 5n;
2959
3302
  if (crc5(data, 50) !== Number(v & 31n)) return { error: "bad_code" };
2960
- if (data >> 48n !== 0n) return { error: "unknown_version" };
3303
+ const version = data >> 48n;
3304
+ if (version === 1n) return { room: (data & 0xffffffffffffn).toString(16).padStart(12, "0") };
3305
+ if (version !== 0n) return { error: "unknown_version" };
2961
3306
  const port = Number(data & 0xffffn);
2962
3307
  if (port === 0) return { error: "bad_code" };
2963
3308
  const a = Number(data >> 16n & 0xffffffffn);
@@ -2967,6 +3312,855 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2967
3312
  return !/[.:]/.test(input) && normalizeJoinCode(input) !== null;
2968
3313
  }
2969
3314
 
3315
+ // src/nostr/limits.ts
3316
+ var BATCH_MS = 30;
3317
+ var RESEND_MS = 3e3;
3318
+ var MAX_RESENDS = 5;
3319
+ var BEAT_MS = 1e4;
3320
+ var SILENCE_MS2 = 3e4;
3321
+ var MAX_HELD = 64;
3322
+ var MAX_OUT_FRAMES = 128;
3323
+ var MAX_OUT_BYTES = 256 * 1024;
3324
+ var MAX_FRAME_BYTES2 = 3e4;
3325
+ var TICK_MS = 250;
3326
+ var NEW_SESSION_BURST = 16;
3327
+ var MAX_SESSION_BURST = 64;
3328
+ var NEW_SESSION_REFILL_MS = 1e3;
3329
+ var WAITING_MAX = 64;
3330
+ var WAITING_STALE_MS = 5e3;
3331
+ var OPEN_EVERY_MS = 2e3;
3332
+ var GIVE_UP_MS = 15e3;
3333
+ var HOST_NOSTR_FIRST_RELAY_MS = 1e4;
3334
+ var JOINER_FIRST_RELAY_MS = 1e4;
3335
+ var RELAY_REST_WAIT_MS = 1500;
3336
+ var RELAY_REST_POLL_MS = 50;
3337
+ var DEAD_RELAY_DIALS = 4;
3338
+
3339
+ // src/nostr/link.ts
3340
+ var utf8 = new TextEncoder();
3341
+ var byteLen = (s) => utf8.encode(s).length;
3342
+ var ReliableLink = class {
3343
+ o;
3344
+ queue = [];
3345
+ queueBytes = 0;
3346
+ unackedBytes = 0;
3347
+ nextSeq = 1;
3348
+ unacked = /* @__PURE__ */ new Map();
3349
+ rcvNext = 1;
3350
+ // next seq to deliver
3351
+ held = /* @__PURE__ */ new Map();
3352
+ ackOwed = false;
3353
+ lastSent;
3354
+ lastHeard;
3355
+ cancelFlush = null;
3356
+ cancelTick = null;
3357
+ done = false;
3358
+ constructor(o) {
3359
+ this.o = o;
3360
+ this.lastSent = this.lastHeard = o.clock.now();
3361
+ this.cancelTick = o.clock.setInterval(() => this.tick(), TICK_MS);
3362
+ }
3363
+ get closed() {
3364
+ return this.done;
3365
+ }
3366
+ /** Queues a hub text; sent within 30 ms, packed with its neighbours. */
3367
+ send(text) {
3368
+ if (this.done) return;
3369
+ const n = byteLen(text);
3370
+ if (this.queue.length + this.unacked.size >= MAX_OUT_FRAMES || this.queueBytes + this.unackedBytes + n > MAX_OUT_BYTES) return;
3371
+ this.queue.push(text);
3372
+ this.queueBytes += n;
3373
+ this.scheduleFlush();
3374
+ }
3375
+ /** Any frame arrived from the peer (including `open`, which only proves it is alive). */
3376
+ heard() {
3377
+ this.lastHeard = this.o.clock.now();
3378
+ }
3379
+ /** `data`, `beat` and `close` frames from the peer. */
3380
+ receive(f) {
3381
+ if (this.done) return;
3382
+ this.heard();
3383
+ if (f.type === "close") {
3384
+ const reason = typeof f.reason === "string" ? f.reason : "";
3385
+ this.stop();
3386
+ this.o.onPeerClose(reason);
3387
+ return;
3388
+ }
3389
+ if (f.type !== "data" && f.type !== "beat") return;
3390
+ if (Number.isInteger(f.ack)) {
3391
+ for (const s of [...this.unacked.keys()]) if (s <= f.ack) this.dropUnacked(s);
3392
+ }
3393
+ if (f.type === "beat") return;
3394
+ const msgs = Array.isArray(f.msgs) ? f.msgs.filter((m) => typeof m === "string") : [];
3395
+ if (!Number.isInteger(f.seq) || f.seq < 1) return;
3396
+ this.ackOwed = true;
3397
+ if (f.seq === this.rcvNext) {
3398
+ this.rcvNext++;
3399
+ for (const m of msgs) this.o.deliver(m);
3400
+ for (let h = this.held.get(this.rcvNext); h && !this.done; h = this.held.get(this.rcvNext)) {
3401
+ this.held.delete(this.rcvNext);
3402
+ this.rcvNext++;
3403
+ for (const m of h) this.o.deliver(m);
3404
+ }
3405
+ } else if (f.seq > this.rcvNext && f.seq <= this.rcvNext + MAX_HELD) this.held.set(f.seq, msgs);
3406
+ this.scheduleFlush();
3407
+ }
3408
+ /** Sends what is queued, then `close` (once), and stops. */
3409
+ close(reason = "") {
3410
+ if (this.done) return;
3411
+ this.flush();
3412
+ this.o.send({ type: "close", seq: 0, ack: this.rcvNext - 1, ...reason ? { reason } : {} });
3413
+ this.stop();
3414
+ }
3415
+ /** Stops the timers without telling the peer. */
3416
+ stop() {
3417
+ this.done = true;
3418
+ this.cancelFlush?.();
3419
+ this.cancelTick?.();
3420
+ this.cancelFlush = this.cancelTick = null;
3421
+ this.unacked.clear();
3422
+ this.unackedBytes = 0;
3423
+ this.held.clear();
3424
+ this.queue = [];
3425
+ this.queueBytes = 0;
3426
+ }
3427
+ scheduleFlush() {
3428
+ if (this.cancelFlush || this.done) return;
3429
+ this.cancelFlush = this.o.clock.setTimeout(() => {
3430
+ this.cancelFlush = null;
3431
+ this.flush();
3432
+ }, BATCH_MS);
3433
+ }
3434
+ flush() {
3435
+ if (this.done) return;
3436
+ this.cancelFlush?.();
3437
+ this.cancelFlush = null;
3438
+ const now = this.o.clock.now();
3439
+ if (this.queue.length === 0) {
3440
+ if (this.ackOwed) this.emit({ type: "beat", seq: 0, ack: this.rcvNext - 1 }, now);
3441
+ return;
3442
+ }
3443
+ let batch = [];
3444
+ let bytes = 0;
3445
+ const ship = () => {
3446
+ if (batch.length === 0) return;
3447
+ const frame = { type: "data", seq: this.nextSeq++, ack: this.rcvNext - 1, msgs: batch };
3448
+ this.unacked.set(frame.seq, { bytes, frame, lastSent: now, resends: 0 });
3449
+ this.unackedBytes += bytes;
3450
+ this.emit(frame, now);
3451
+ batch = [];
3452
+ bytes = 0;
3453
+ };
3454
+ this.queueBytes = 0;
3455
+ for (const m of this.queue.splice(0)) {
3456
+ const n = byteLen(m);
3457
+ if (batch.length > 0 && bytes + n > MAX_FRAME_BYTES2) ship();
3458
+ batch.push(m);
3459
+ bytes += n;
3460
+ }
3461
+ ship();
3462
+ }
3463
+ dropUnacked(seq) {
3464
+ const p = this.unacked.get(seq);
3465
+ if (!p) return;
3466
+ this.unackedBytes -= p.bytes;
3467
+ this.unacked.delete(seq);
3468
+ }
3469
+ emit(f, now) {
3470
+ this.lastSent = now;
3471
+ this.ackOwed = false;
3472
+ this.o.send(f);
3473
+ }
3474
+ tick() {
3475
+ if (this.done) return;
3476
+ const now = this.o.clock.now();
3477
+ if (now - this.lastHeard >= SILENCE_MS2) return this.die();
3478
+ for (const p of this.unacked.values()) {
3479
+ if (now - p.lastSent < RESEND_MS) continue;
3480
+ if (p.resends >= MAX_RESENDS) return this.die();
3481
+ p.resends++;
3482
+ p.lastSent = now;
3483
+ this.o.send({ ...p.frame, ack: this.rcvNext - 1 });
3484
+ this.lastSent = now;
3485
+ }
3486
+ if (now - this.lastSent >= BEAT_MS) this.emit({ type: "beat", seq: 0, ack: this.rcvNext - 1 }, now);
3487
+ }
3488
+ die() {
3489
+ this.stop();
3490
+ this.o.onDead("timeout");
3491
+ }
3492
+ };
3493
+
3494
+ // src/nostr/crypto.ts
3495
+ var subtle = () => {
3496
+ const s = globalThis.crypto?.subtle;
3497
+ if (!s) throw new Error("WebCrypto (crypto.subtle) is not available; Nostr signaling needs a secure context or Node 18+");
3498
+ return s;
3499
+ };
3500
+ var buf = (b) => b;
3501
+ var utf82 = new TextEncoder();
3502
+ function concatBytes(...parts) {
3503
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
3504
+ let o = 0;
3505
+ for (const p of parts) {
3506
+ out.set(p, o);
3507
+ o += p.length;
3508
+ }
3509
+ return out;
3510
+ }
3511
+ var toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
3512
+ function fromHex(s) {
3513
+ if (s.length % 2 || /[^0-9a-fA-F]/.test(s)) throw new Error("bad hex");
3514
+ const out = new Uint8Array(s.length / 2);
3515
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
3516
+ return out;
3517
+ }
3518
+ function toBase64(b) {
3519
+ let s = "";
3520
+ for (let i = 0; i < b.length; i += 32768) s += String.fromCharCode(...b.subarray(i, i + 32768));
3521
+ return btoa(s);
3522
+ }
3523
+ function fromBase64(s) {
3524
+ const bin = atob(s);
3525
+ const out = new Uint8Array(bin.length);
3526
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
3527
+ return out;
3528
+ }
3529
+ async function sha256(data) {
3530
+ return new Uint8Array(await subtle().digest("SHA-256", buf(data)));
3531
+ }
3532
+ async function hkdf(ikm, salt, info, length = 32) {
3533
+ const key = await subtle().importKey("raw", buf(ikm), "HKDF", false, ["deriveBits"]);
3534
+ const bits = await subtle().deriveBits({ name: "HKDF", hash: "SHA-256", salt: buf(utf82.encode(salt)), info: buf(utf82.encode(info)) }, key, length * 8);
3535
+ return new Uint8Array(bits);
3536
+ }
3537
+ async function deriveRoomKeys(normalizedCode) {
3538
+ const ikm = utf82.encode(normalizedCode);
3539
+ const salt = "onelobby-nostr-v1";
3540
+ const [key, topic] = await Promise.all([hkdf(ikm, salt, "key"), hkdf(ikm, salt, "topic")]);
3541
+ return { key, topic: toHex(topic) };
3542
+ }
3543
+ async function encryptContent(key, plaintext, iv) {
3544
+ const k = await subtle().importKey("raw", buf(key), "AES-GCM", false, ["encrypt"]);
3545
+ const v = iv ?? globalThis.crypto.getRandomValues(new Uint8Array(12));
3546
+ const ct = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: buf(v) }, k, buf(utf82.encode(plaintext))));
3547
+ return toBase64(concatBytes(v, ct));
3548
+ }
3549
+ async function decryptContent(key, content) {
3550
+ try {
3551
+ const raw = fromBase64(content);
3552
+ if (raw.length < 12 + 16) return null;
3553
+ const k = await subtle().importKey("raw", buf(key), "AES-GCM", false, ["decrypt"]);
3554
+ const pt = await subtle().decrypt({ name: "AES-GCM", iv: buf(raw.subarray(0, 12)) }, k, buf(raw.subarray(12)));
3555
+ return new TextDecoder().decode(pt);
3556
+ } catch {
3557
+ return null;
3558
+ }
3559
+ }
3560
+
3561
+ // src/nostr/secp256k1.ts
3562
+ var P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
3563
+ var N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
3564
+ var GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
3565
+ var GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
3566
+ var INF = [0n, 1n, 0n];
3567
+ var G = [GX, GY, 1n];
3568
+ var mod = (a, m = P) => {
3569
+ const r = a % m;
3570
+ return r >= 0n ? r : r + m;
3571
+ };
3572
+ function pow(b, e, m) {
3573
+ let r = 1n;
3574
+ b = mod(b, m);
3575
+ while (e > 0n) {
3576
+ if (e & 1n) r = r * b % m;
3577
+ b = b * b % m;
3578
+ e >>= 1n;
3579
+ }
3580
+ return r;
3581
+ }
3582
+ var inv = (a, m = P) => pow(a, m - 2n, m);
3583
+ function dbl(p) {
3584
+ const [X, Y, Z] = p;
3585
+ if (Z === 0n || Y === 0n) return INF;
3586
+ const A = X * X % P;
3587
+ const B = Y * Y % P;
3588
+ const C = B * B % P;
3589
+ const D = mod(2n * ((X + B) * (X + B) - A - C));
3590
+ const E = 3n * A;
3591
+ const F = E * E % P;
3592
+ const X3 = mod(F - 2n * D);
3593
+ return [X3, mod(E * (D - X3) - 8n * C), mod(2n * Y * Z)];
3594
+ }
3595
+ function add(p, q) {
3596
+ if (p[2] === 0n) return q;
3597
+ if (q[2] === 0n) return p;
3598
+ const [X1, Y1, Z1] = p;
3599
+ const [X2, Y2, Z2] = q;
3600
+ const Z1Z1 = Z1 * Z1 % P;
3601
+ const Z2Z2 = Z2 * Z2 % P;
3602
+ const U1 = X1 * Z2Z2 % P;
3603
+ const U2 = X2 * Z1Z1 % P;
3604
+ const S1 = Y1 * Z2 * Z2Z2 % P;
3605
+ const S2 = Y2 * Z1 * Z1Z1 % P;
3606
+ if (U1 === U2) return S1 === S2 ? dbl(p) : INF;
3607
+ const H = mod(U2 - U1);
3608
+ const I = 4n * H * H % P;
3609
+ const J = H * I % P;
3610
+ const r = mod(2n * (S2 - S1));
3611
+ const V = U1 * I % P;
3612
+ const X3 = mod(r * r - J - 2n * V);
3613
+ const Y3 = mod(r * (V - X3) - 2n * S1 * J);
3614
+ const Z3 = mod(((Z1 + Z2) * (Z1 + Z2) - Z1Z1 - Z2Z2) * H);
3615
+ return [X3, Y3, Z3];
3616
+ }
3617
+ function mul(p, k) {
3618
+ let r = INF;
3619
+ let q = p;
3620
+ while (k > 0n) {
3621
+ if (k & 1n) r = add(r, q);
3622
+ q = dbl(q);
3623
+ k >>= 1n;
3624
+ }
3625
+ return r;
3626
+ }
3627
+ function affine(p) {
3628
+ if (p[2] === 0n) return null;
3629
+ const zi = inv(p[2]);
3630
+ const zi2 = zi * zi % P;
3631
+ return [p[0] * zi2 % P, p[1] * zi2 * zi % P];
3632
+ }
3633
+ var bytesToInt = (b) => {
3634
+ let n = 0n;
3635
+ for (const x of b) n = n << 8n | BigInt(x);
3636
+ return n;
3637
+ };
3638
+ function intToBytes(n, len = 32) {
3639
+ const out = new Uint8Array(len);
3640
+ for (let i = len - 1; i >= 0; i--) {
3641
+ out[i] = Number(n & 0xffn);
3642
+ n >>= 8n;
3643
+ }
3644
+ return out;
3645
+ }
3646
+ async function taggedHash(tag, ...msgs) {
3647
+ const t = await sha256(new TextEncoder().encode(tag));
3648
+ return sha256(concatBytes(t, t, ...msgs));
3649
+ }
3650
+ function schnorrPublicKey(secret) {
3651
+ const d = bytesToInt(secret);
3652
+ if (d < 1n || d >= N) throw new Error("secret key out of range");
3653
+ const pt = affine(mul(G, d));
3654
+ return intToBytes(pt[0]);
3655
+ }
3656
+ function randomSecretKey() {
3657
+ for (; ; ) {
3658
+ const b = new Uint8Array(32);
3659
+ globalThis.crypto.getRandomValues(b);
3660
+ const d = bytesToInt(b);
3661
+ if (d >= 1n && d < N) return b;
3662
+ }
3663
+ }
3664
+ async function schnorrSign(msg, secret, aux) {
3665
+ const dPrime = bytesToInt(secret);
3666
+ if (dPrime < 1n || dPrime >= N) throw new Error("secret key out of range");
3667
+ const pt = affine(mul(G, dPrime));
3668
+ const d = pt[1] & 1n ? N - dPrime : dPrime;
3669
+ const px = intToBytes(pt[0]);
3670
+ const a = aux ?? globalThis.crypto.getRandomValues(new Uint8Array(32));
3671
+ const th = await taggedHash("BIP0340/aux", a);
3672
+ const dBytes = intToBytes(d);
3673
+ const t = dBytes.map((x, i) => x ^ th[i]);
3674
+ const rand = await taggedHash("BIP0340/nonce", t, px, msg);
3675
+ const kPrime = bytesToInt(rand) % N;
3676
+ if (kPrime === 0n) throw new Error("bad nonce");
3677
+ const r = affine(mul(G, kPrime));
3678
+ const k = r[1] & 1n ? N - kPrime : kPrime;
3679
+ const rx = intToBytes(r[0]);
3680
+ const e = bytesToInt(await taggedHash("BIP0340/challenge", rx, px, msg)) % N;
3681
+ return concatBytes(rx, intToBytes((k + e * d) % N));
3682
+ }
3683
+
3684
+ // src/nostr/relays.ts
3685
+ var NOSTR_KIND = 25050;
3686
+ async function eventId(pubkey, createdAt, kind, tags, content) {
3687
+ return toHex(await sha256(new TextEncoder().encode(JSON.stringify([0, pubkey, createdAt, kind, tags, content]))));
3688
+ }
3689
+ async function makeEvent(secret, topic, content, createdAt) {
3690
+ const pubkey = toHex(schnorrPublicKey(secret));
3691
+ const tags = [["t", topic]];
3692
+ const id = await eventId(pubkey, createdAt, NOSTR_KIND, tags, content);
3693
+ const sig = toHex(await schnorrSign(fromHex(id), secret));
3694
+ return { id, pubkey, created_at: createdAt, kind: NOSTR_KIND, tags, content, sig };
3695
+ }
3696
+ var defaultRelayConnect = (url) => {
3697
+ const WS = globalThis.WebSocket;
3698
+ if (!WS) throw new Error("no WebSocket in this runtime (Node 22+ has one; older Node needs advanced.nostrConnect)");
3699
+ return new WS(url);
3700
+ };
3701
+ var SEEN_MAX = 4096;
3702
+ var SINCE_SLACK_S = 30;
3703
+ var RelayPool = class {
3704
+ constructor(o) {
3705
+ this.o = o;
3706
+ this.relays = o.urls.map((url) => ({ url, ws: null, open: false, attempted: false, skipped: false, everOpen: false, fails: 0, backoff: 1e3, cancelRetry: null }));
3707
+ }
3708
+ o;
3709
+ relays;
3710
+ seen = /* @__PURE__ */ new Set();
3711
+ sub = "ol" + Math.random().toString(16).slice(2, 10);
3712
+ closed = false;
3713
+ waiters = [];
3714
+ get total() {
3715
+ return this.relays.length;
3716
+ }
3717
+ /** Relays connected right now (and not skipped). */
3718
+ get connected() {
3719
+ return this.relays.filter((r) => r.open && !r.skipped).length;
3720
+ }
3721
+ /** Every relay has either connected, failed a connect, or been skipped: waiting longer for the rest is pointless. */
3722
+ get settled() {
3723
+ return this.relays.every((r) => r.attempted || r.skipped);
3724
+ }
3725
+ start() {
3726
+ for (const r of this.relays) this.dial(r);
3727
+ }
3728
+ /** Resolves true once one relay is connected, false after `timeoutMs` (or when closed). */
3729
+ whenUp(timeoutMs) {
3730
+ if (this.connected > 0) return Promise.resolve(true);
3731
+ return new Promise((resolve) => {
3732
+ const cancel = this.o.clock.setTimeout(() => done(false), timeoutMs);
3733
+ const done = (v) => {
3734
+ cancel();
3735
+ this.waiters = this.waiters.filter((w) => w !== check2);
3736
+ resolve(v);
3737
+ };
3738
+ const check2 = () => {
3739
+ if (this.connected > 0) done(true);
3740
+ else if (this.closed) done(false);
3741
+ };
3742
+ this.waiters.push(check2);
3743
+ });
3744
+ }
3745
+ publish(ev) {
3746
+ const msg = JSON.stringify(["EVENT", ev]);
3747
+ for (const r of this.relays) if (r.open && !r.skipped) this.trySend(r, msg);
3748
+ }
3749
+ close() {
3750
+ this.closed = true;
3751
+ for (const r of this.relays) {
3752
+ r.cancelRetry?.();
3753
+ r.cancelRetry = null;
3754
+ this.drop(r, false);
3755
+ }
3756
+ this.changed();
3757
+ }
3758
+ changed() {
3759
+ for (const w of [...this.waiters]) w();
3760
+ this.o.onChange?.();
3761
+ }
3762
+ trySend(r, msg) {
3763
+ try {
3764
+ r.ws?.send(msg);
3765
+ } catch {
3766
+ }
3767
+ }
3768
+ dial(r) {
3769
+ if (this.closed || r.skipped) return;
3770
+ let ws;
3771
+ try {
3772
+ ws = this.o.connect(r.url);
3773
+ } catch {
3774
+ r.attempted = true;
3775
+ this.retryLater(r);
3776
+ return;
3777
+ }
3778
+ r.ws = ws;
3779
+ ws.onopen = () => {
3780
+ if (r.ws !== ws) return;
3781
+ r.open = true;
3782
+ r.attempted = true;
3783
+ r.everOpen = true;
3784
+ r.fails = 0;
3785
+ r.backoff = 1e3;
3786
+ const since = Math.floor(this.o.wall() / 1e3) - SINCE_SLACK_S;
3787
+ this.trySend(r, JSON.stringify(["REQ", this.sub, { kinds: [NOSTR_KIND], "#t": [this.o.topic], since }]));
3788
+ this.changed();
3789
+ };
3790
+ ws.onmessage = (e) => {
3791
+ if (r.ws === ws) this.onMessage(r, String(e.data));
3792
+ };
3793
+ const lost = () => {
3794
+ if (r.ws !== ws) return;
3795
+ const wasOpen = r.open;
3796
+ r.ws = null;
3797
+ r.open = false;
3798
+ r.attempted = true;
3799
+ if (wasOpen) this.changed();
3800
+ this.retryLater(r);
3801
+ };
3802
+ ws.onclose = lost;
3803
+ ws.onerror = () => {
3804
+ lost();
3805
+ };
3806
+ }
3807
+ drop(r, retry) {
3808
+ const ws = r.ws;
3809
+ r.ws = null;
3810
+ r.open = false;
3811
+ try {
3812
+ ws?.close();
3813
+ } catch {
3814
+ }
3815
+ if (retry) this.retryLater(r);
3816
+ }
3817
+ retryLater(r) {
3818
+ if (this.closed || r.skipped || r.cancelRetry) return;
3819
+ if (!r.everOpen) r.fails++;
3820
+ if (!r.everOpen && r.fails >= DEAD_RELAY_DIALS && this.connected > 0) {
3821
+ this.skip(r);
3822
+ return;
3823
+ }
3824
+ const wait = r.backoff;
3825
+ r.backoff = Math.min(3e4, r.backoff * 2);
3826
+ r.cancelRetry = this.o.clock.setTimeout(() => {
3827
+ r.cancelRetry = null;
3828
+ this.dial(r);
3829
+ }, wait);
3830
+ }
3831
+ skip(r) {
3832
+ if (r.skipped) return;
3833
+ r.skipped = true;
3834
+ r.cancelRetry?.();
3835
+ r.cancelRetry = null;
3836
+ this.drop(r, false);
3837
+ this.changed();
3838
+ }
3839
+ onMessage(r, text) {
3840
+ let m;
3841
+ try {
3842
+ m = JSON.parse(text);
3843
+ } catch {
3844
+ return;
3845
+ }
3846
+ if (!Array.isArray(m)) return;
3847
+ switch (m[0]) {
3848
+ case "EVENT": {
3849
+ const ev = m[2];
3850
+ if (!ev || typeof ev.id !== "string" || typeof ev.pubkey !== "string" || typeof ev.content !== "string" || typeof ev.created_at !== "number") return;
3851
+ if (ev.kind !== NOSTR_KIND || !Array.isArray(ev.tags) || !ev.tags.some((t) => t[0] === "t" && t[1] === this.o.topic)) return;
3852
+ if (this.seen.has(ev.id)) return;
3853
+ this.seen.add(ev.id);
3854
+ if (this.seen.size > SEEN_MAX) this.seen.delete(this.seen.values().next().value);
3855
+ this.o.onEvent(ev);
3856
+ return;
3857
+ }
3858
+ case "OK":
3859
+ if (m[2] === false && !String(m[3] ?? "").startsWith("duplicate")) this.skip(r);
3860
+ return;
3861
+ case "CLOSED":
3862
+ this.skip(r);
3863
+ return;
3864
+ }
3865
+ }
3866
+ };
3867
+ var DEFAULT_NOSTR_RELAYS = [
3868
+ "wss://nos.lol",
3869
+ "wss://relay.primal.net",
3870
+ "wss://nostr-pub.wellorder.net",
3871
+ "wss://nostr.oxtr.dev",
3872
+ "wss://nostr.bitcoiner.social",
3873
+ "wss://relay.nostr.net",
3874
+ "wss://nostr.sathoarder.com"
3875
+ ];
3876
+ var DEFAULT_NOSTR_STUN = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"];
3877
+ async function checkNostrRelays(urls, timeoutMs = 8e3, opts = {}) {
3878
+ const connect = opts.connect ?? defaultRelayConnect;
3879
+ const setT = (fn, ms) => {
3880
+ const h = setTimeout(fn, ms);
3881
+ return () => clearTimeout(h);
3882
+ };
3883
+ return Promise.all(urls.map((url) => checkOne(url, timeoutMs, connect, opts.clock?.setTimeout.bind(opts.clock) ?? setT)));
3884
+ }
3885
+ async function checkOne(url, timeoutMs, connect, setT) {
3886
+ const t0 = Date.now();
3887
+ const secret = new Uint8Array(32);
3888
+ globalThis.crypto.getRandomValues(secret);
3889
+ secret[0] &= 127;
3890
+ if (secret.every((b) => b === 0)) secret[31] = 1;
3891
+ const topic = toHex(globalThis.crypto.getRandomValues(new Uint8Array(32)));
3892
+ let ev;
3893
+ try {
3894
+ ev = await makeEvent(secret, topic, "probe", Math.floor(Date.now() / 1e3));
3895
+ } catch (e) {
3896
+ return { url, ok: false, ms: 0, error: String(e) };
3897
+ }
3898
+ return new Promise((resolve) => {
3899
+ let ws = null;
3900
+ let finished = false;
3901
+ let accepted = false;
3902
+ const finish = (ok, error) => {
3903
+ if (finished) return;
3904
+ finished = true;
3905
+ cancel();
3906
+ try {
3907
+ ws?.close();
3908
+ } catch {
3909
+ }
3910
+ resolve({ url, ok, ms: Date.now() - t0, ...error ? { error } : {} });
3911
+ };
3912
+ const cancel = setT(() => finish(false, accepted ? "accepted but not echoed" : "timeout"), timeoutMs);
3913
+ try {
3914
+ ws = connect(url);
3915
+ } catch (e) {
3916
+ finish(false, String(e));
3917
+ return;
3918
+ }
3919
+ const sub = "chk" + topic.slice(0, 8);
3920
+ let published = false;
3921
+ const publish = () => {
3922
+ if (published) return;
3923
+ published = true;
3924
+ ws.send(JSON.stringify(["EVENT", ev]));
3925
+ };
3926
+ ws.onopen = () => {
3927
+ ws.send(JSON.stringify(["REQ", sub, { kinds: [NOSTR_KIND], "#t": [topic], since: Math.floor(Date.now() / 1e3) - 30 }]));
3928
+ setT(publish, 1500);
3929
+ };
3930
+ ws.onerror = () => finish(false, "connection failed");
3931
+ ws.onclose = () => finish(false, "closed");
3932
+ ws.onmessage = (e) => {
3933
+ let m;
3934
+ try {
3935
+ m = JSON.parse(String(e.data));
3936
+ } catch {
3937
+ return;
3938
+ }
3939
+ if (!Array.isArray(m)) return;
3940
+ if (m[0] === "EOSE") publish();
3941
+ else if (m[0] === "OK" && m[1] === ev.id) {
3942
+ if (m[2] === true) accepted = true;
3943
+ else finish(false, `refused: ${String(m[3] ?? "")}`.slice(0, 120));
3944
+ } else if (m[0] === "CLOSED") finish(false, `closed: ${String(m[2] ?? "")}`.slice(0, 120));
3945
+ else if (m[0] === "EVENT" && m[2]?.id === ev.id) finish(true);
3946
+ };
3947
+ });
3948
+ }
3949
+
3950
+ // src/nostr/room.ts
3951
+ var MAX_AGE_S = 120;
3952
+ var NostrRoom = class _NostrRoom {
3953
+ constructor(secret, keys, o, wall) {
3954
+ this.secret = secret;
3955
+ this.keys = keys;
3956
+ this.wall = wall;
3957
+ this.pub = toHex(schnorrPublicKey(secret));
3958
+ this.pool = new RelayPool({
3959
+ urls: o.relays && o.relays.length > 0 ? o.relays : DEFAULT_NOSTR_RELAYS,
3960
+ topic: keys.topic,
3961
+ connect: o.connect ?? defaultRelayConnect,
3962
+ clock: o.clock,
3963
+ wall,
3964
+ onEvent: (ev) => void this.onEvent(ev),
3965
+ onChange: o.onChange
3966
+ });
3967
+ }
3968
+ secret;
3969
+ keys;
3970
+ wall;
3971
+ pub;
3972
+ /** Called for each valid frame addressed to nobody in particular or to us (`to` empty or our pubkey). */
3973
+ onFrame = null;
3974
+ pool;
3975
+ closed = false;
3976
+ pending = /* @__PURE__ */ new Set();
3977
+ /** Derives the keys and starts connecting. Throws on a code that is not 11 alphabet characters. */
3978
+ static async open(o) {
3979
+ const norm = normalizeJoinCode(o.code);
3980
+ if (!norm) throw new Error(`not a join code: ${o.code}`);
3981
+ const keys = await deriveRoomKeys(norm);
3982
+ const room = new _NostrRoom(randomSecretKey(), keys, o, o.wall ?? (() => Date.now()));
3983
+ room.pool.start();
3984
+ return room;
3985
+ }
3986
+ get relaysConnected() {
3987
+ return this.pool.connected;
3988
+ }
3989
+ get relaysTotal() {
3990
+ return this.pool.total;
3991
+ }
3992
+ /** Every relay has connected, failed or been skipped. */
3993
+ get relaysSettled() {
3994
+ return this.pool.settled;
3995
+ }
3996
+ whenUp(timeoutMs) {
3997
+ return this.pool.whenUp(timeoutMs);
3998
+ }
3999
+ /** Stamps, encrypts, signs and publishes a frame to every relay. */
4000
+ send(to, f) {
4001
+ const p = this.sendNow(to, f);
4002
+ this.pending.add(p);
4003
+ void p.finally(() => this.pending.delete(p));
4004
+ return p;
4005
+ }
4006
+ async sendNow(to, f) {
4007
+ if (this.closed) return;
4008
+ const frame = { v: 1, type: f.type, from: this.pub, to, seq: f.seq, ack: f.ack, ...f.msgs ? { msgs: f.msgs } : {}, ...f.reason ? { reason: f.reason } : {} };
4009
+ const content = await encryptContent(this.keys.key, JSON.stringify(frame));
4010
+ const ev = await makeEvent(this.secret, this.keys.topic, content, Math.floor(this.wall() / 1e3));
4011
+ if (!this.closed) this.pool.publish(ev);
4012
+ }
4013
+ /** Closes now, or (`flush`) once the frames already handed to `send` are published (a final `close` frame). */
4014
+ close(flush = false) {
4015
+ if (flush && this.pending.size > 0) {
4016
+ void Promise.allSettled([...this.pending]).then(() => this.close());
4017
+ return;
4018
+ }
4019
+ this.closed = true;
4020
+ this.onFrame = null;
4021
+ this.pool.close();
4022
+ }
4023
+ async onEvent(ev) {
4024
+ if (this.closed || ev.pubkey === this.pub) return;
4025
+ if (this.wall() / 1e3 - ev.created_at > MAX_AGE_S) return;
4026
+ const text = await decryptContent(this.keys.key, ev.content);
4027
+ if (text === null || this.closed) return;
4028
+ let f;
4029
+ try {
4030
+ f = JSON.parse(text);
4031
+ } catch {
4032
+ return;
4033
+ }
4034
+ if (!f || f.v !== 1 || f.from !== ev.pubkey || f.from === this.pub) return;
4035
+ if (f.type !== "open" && f.type !== "data" && f.type !== "beat" && f.type !== "close") return;
4036
+ if (typeof f.to !== "string" || f.to !== "" && f.to !== this.pub) return;
4037
+ this.onFrame?.(f);
4038
+ }
4039
+ };
4040
+
4041
+ // src/nostr/socket.ts
4042
+ var NostrSocket = class {
4043
+ constructor(o) {
4044
+ this.o = o;
4045
+ void this.start();
4046
+ }
4047
+ o;
4048
+ readyState = 0;
4049
+ onopen = null;
4050
+ onmessage = null;
4051
+ onclose = null;
4052
+ onerror = null;
4053
+ /** Why the socket failed or closed, for messages ("no Nostr relay reachable", "host did not answer"). */
4054
+ failure = "";
4055
+ room = null;
4056
+ link = null;
4057
+ hostPub = "";
4058
+ cancelOpen = null;
4059
+ cancelDeadline = null;
4060
+ sendOpen = null;
4061
+ relaysSeen = 0;
4062
+ /** Relays connected, for a progress line. */
4063
+ get relaysConnected() {
4064
+ return this.room?.relaysConnected ?? 0;
4065
+ }
4066
+ get relaysTotal() {
4067
+ return this.room?.relaysTotal ?? 0;
4068
+ }
4069
+ send(data) {
4070
+ if (this.readyState !== 1 || !this.link) return;
4071
+ if (data.length < 64 && data.startsWith('{"t":"ping"')) {
4072
+ try {
4073
+ const m = JSON.parse(data);
4074
+ queueMicrotask(() => this.readyState === 1 && this.onmessage?.({ data: JSON.stringify({ t: "pong", n: m.n }) }));
4075
+ return;
4076
+ } catch {
4077
+ }
4078
+ }
4079
+ this.link.send(data);
4080
+ }
4081
+ close(_code, reason) {
4082
+ if (this.readyState === 3) return;
4083
+ const wasOpen = this.readyState === 1;
4084
+ if (wasOpen) this.link?.close(reason ?? "");
4085
+ this.finish(1e3, reason ?? "", false);
4086
+ }
4087
+ async start() {
4088
+ const { clock } = this.o;
4089
+ try {
4090
+ this.room = await NostrRoom.open({ ...this.o, onChange: () => this.relaysChanged() });
4091
+ } catch (e) {
4092
+ this.fail(String(e));
4093
+ return;
4094
+ }
4095
+ const room = this.room;
4096
+ if (this.readyState === 3) {
4097
+ room.close();
4098
+ return;
4099
+ }
4100
+ room.onFrame = (f) => this.onFrame(f);
4101
+ this.cancelDeadline = clock.setTimeout(() => this.fail("the host did not answer through the Nostr relays"), this.o.openTimeoutMs ?? GIVE_UP_MS);
4102
+ if (!await room.whenUp(this.o.relayTimeoutMs ?? JOINER_FIRST_RELAY_MS)) {
4103
+ this.fail("no Nostr relay reachable");
4104
+ return;
4105
+ }
4106
+ if (this.readyState !== 0) return;
4107
+ const open = () => void room.send("", { type: "open", seq: 0, ack: 0 });
4108
+ this.relaysSeen = room.relaysConnected;
4109
+ this.sendOpen = open;
4110
+ open();
4111
+ this.cancelOpen = clock.setInterval(open, this.o.openEveryMs ?? OPEN_EVERY_MS);
4112
+ }
4113
+ relaysChanged() {
4114
+ const up = this.room?.relaysConnected ?? 0;
4115
+ if (up > this.relaysSeen && this.readyState === 0) this.sendOpen?.();
4116
+ this.relaysSeen = up;
4117
+ }
4118
+ onFrame(f) {
4119
+ if (this.readyState === 3 || f.to !== this.room?.pub) return;
4120
+ if (this.readyState === 0) {
4121
+ if (f.type !== "open") return;
4122
+ this.hostPub = f.from;
4123
+ this.sendOpen = null;
4124
+ this.cancelOpen?.();
4125
+ this.cancelDeadline?.();
4126
+ this.cancelOpen = this.cancelDeadline = null;
4127
+ const room = this.room;
4128
+ this.link = new ReliableLink({
4129
+ clock: this.o.clock,
4130
+ send: (out) => void room.send(this.hostPub, out),
4131
+ deliver: (text) => this.readyState === 1 && this.onmessage?.({ data: text }),
4132
+ onPeerClose: (reason) => this.finish(1e3, reason, true),
4133
+ onDead: (reason) => this.finish(1006, reason, false)
4134
+ });
4135
+ this.readyState = 1;
4136
+ this.onopen?.();
4137
+ return;
4138
+ }
4139
+ if (f.from !== this.hostPub) return;
4140
+ if (f.type === "open") {
4141
+ this.link?.heard();
4142
+ return;
4143
+ }
4144
+ this.link?.receive(f);
4145
+ }
4146
+ fail(why) {
4147
+ if (this.readyState === 3) return;
4148
+ this.failure = why;
4149
+ if (this.readyState === 0) this.onerror?.({ message: why });
4150
+ this.finish(1006, why, false);
4151
+ }
4152
+ finish(code, reason, clean) {
4153
+ if (this.readyState === 3) return;
4154
+ this.readyState = 3;
4155
+ this.cancelOpen?.();
4156
+ this.cancelDeadline?.();
4157
+ this.cancelOpen = this.cancelDeadline = null;
4158
+ this.link?.stop();
4159
+ this.room?.close(true);
4160
+ queueMicrotask(() => this.onclose?.({ code, reason, wasClean: clean }));
4161
+ }
4162
+ };
4163
+
2970
4164
  // src/direct-address.ts
2971
4165
  var DEFAULT_DIRECT_PORT = 47810;
2972
4166
  var IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
@@ -3049,6 +4243,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3049
4243
  lobbyServer: str(),
3050
4244
  directPort: int(1024, 65535),
3051
4245
  openPort: bool,
4246
+ nostr: bool,
4247
+ nostrRelays: { k: "strarr" },
3052
4248
  relayOnly: bool,
3053
4249
  allowedOrigins: { k: "strarr" }
3054
4250
  }),
@@ -3196,6 +4392,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3196
4392
  lobbyServer: hosting.lobbyServer ?? "",
3197
4393
  directPort: hosting.directPort ?? 47810,
3198
4394
  openPort: hosting.openPort ?? true,
4395
+ nostr: hosting.nostr ?? true,
4396
+ nostrRelays: hosting.nostrRelays ?? [...DEFAULT_NOSTR_RELAYS],
3199
4397
  relayOnly: hosting.relayOnly ?? false,
3200
4398
  allowedOrigins: hosting.allowedOrigins ?? []
3201
4399
  },
@@ -3251,9 +4449,1354 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3251
4449
  return deepFreeze(b);
3252
4450
  }
3253
4451
 
4452
+ // src/nostr/host.ts
4453
+ function newSessionBurst(maxPlayers) {
4454
+ return Math.max(NEW_SESSION_BURST, Math.min(MAX_SESSION_BURST, maxPlayers ?? 0));
4455
+ }
4456
+ var PING = '{"t":"ping","n":0}';
4457
+ var CLOSED_MEMORY = 512;
4458
+ var NostrHostBridge = class _NostrHostBridge {
4459
+ constructor(hub, room, clock, maxPlayers) {
4460
+ this.hub = hub;
4461
+ this.room = room;
4462
+ this.clock = clock;
4463
+ this.burst = this.newTokens = newSessionBurst(maxPlayers);
4464
+ room.onFrame = (f) => this.onFrame(f);
4465
+ }
4466
+ hub;
4467
+ room;
4468
+ clock;
4469
+ peers = /* @__PURE__ */ new Map();
4470
+ gone = /* @__PURE__ */ new Set();
4471
+ stopped = false;
4472
+ burst;
4473
+ newTokens;
4474
+ newTokensAt = null;
4475
+ /** Refused new pubkeys, oldest first, with when their last `open` was heard. */
4476
+ waiting = /* @__PURE__ */ new Map();
4477
+ cancelWaiting = null;
4478
+ static async start(hub, o) {
4479
+ return new _NostrHostBridge(hub, await NostrRoom.open(o), o.clock, o.maxPlayers);
4480
+ }
4481
+ get relaysConnected() {
4482
+ return this.room.relaysConnected;
4483
+ }
4484
+ get relaysTotal() {
4485
+ return this.room.relaysTotal;
4486
+ }
4487
+ /** Resolves true once one relay is connected, false after `timeoutMs`. */
4488
+ whenUp(timeoutMs) {
4489
+ return this.room.whenUp(timeoutMs);
4490
+ }
4491
+ get relaysSettled() {
4492
+ return this.room.relaysSettled;
4493
+ }
4494
+ /** Waits for the relays: true once one is connected (then up to RELAY_REST_WAIT_MS more, less when every relay has answered), false after `firstRelayMs`. */
4495
+ async settle(firstRelayMs) {
4496
+ if (!await this.whenUp(firstRelayMs)) return false;
4497
+ for (let i = 0; i < RELAY_REST_WAIT_MS / RELAY_REST_POLL_MS && !this.relaysSettled; i++) await new Promise((r) => setTimeout(r, RELAY_REST_POLL_MS));
4498
+ return true;
4499
+ }
4500
+ /** Joiners currently attached. */
4501
+ get peerCount() {
4502
+ return this.peers.size;
4503
+ }
4504
+ close() {
4505
+ if (this.stopped) return;
4506
+ this.stopped = true;
4507
+ this.cancelWaiting?.();
4508
+ this.cancelWaiting = null;
4509
+ this.waiting.clear();
4510
+ for (const [pub, p] of this.peers) {
4511
+ p.link.close("host left");
4512
+ if (p.session) this.hub.disconnect(p.session);
4513
+ this.peers.delete(pub);
4514
+ }
4515
+ this.room.close(true);
4516
+ }
4517
+ onFrame(f) {
4518
+ if (this.stopped) return;
4519
+ const known = this.peers.get(f.from);
4520
+ if (f.type === "open") {
4521
+ if (this.gone.has(f.from)) return;
4522
+ if (known) {
4523
+ known.link.heard();
4524
+ this.answer(f.from);
4525
+ } else if (this.waiting.has(f.from)) {
4526
+ this.waiting.set(f.from, this.clock.now());
4527
+ this.admitWaiting();
4528
+ } else if (this.waiting.size === 0 && this.admitNew()) {
4529
+ this.accept(f.from);
4530
+ } else if (this.waiting.size < WAITING_MAX) {
4531
+ this.waiting.set(f.from, this.clock.now());
4532
+ this.scheduleWaiting();
4533
+ }
4534
+ return;
4535
+ }
4536
+ if (!known) return;
4537
+ known.link.receive(f);
4538
+ if (f.type === "beat" && known.session) this.hub.receive(known.session, PING);
4539
+ }
4540
+ /** Arms one timer for the next token while anyone waits. */
4541
+ scheduleWaiting() {
4542
+ if (this.cancelWaiting || this.waiting.size === 0 || this.stopped) return;
4543
+ const missing = Math.max(0, 1 - this.newTokens);
4544
+ this.cancelWaiting = this.clock.setTimeout(() => {
4545
+ this.cancelWaiting = null;
4546
+ this.admitWaiting();
4547
+ }, Math.max(1, Math.ceil(missing * NEW_SESSION_REFILL_MS)));
4548
+ }
4549
+ /** Answers waiting pubkeys, oldest first, while tokens last; drops the ones that gave up. */
4550
+ admitWaiting() {
4551
+ if (this.stopped) return;
4552
+ const now = this.clock.now();
4553
+ for (const [pub, heard] of this.waiting) {
4554
+ if (now - heard > WAITING_STALE_MS || this.peers.has(pub) || this.gone.has(pub)) {
4555
+ this.waiting.delete(pub);
4556
+ continue;
4557
+ }
4558
+ if (!this.admitNew()) break;
4559
+ this.waiting.delete(pub);
4560
+ this.accept(pub);
4561
+ }
4562
+ this.scheduleWaiting();
4563
+ }
4564
+ /** Spends one token of the new-session bucket, or false when it is empty. */
4565
+ admitNew() {
4566
+ const now = this.clock.now();
4567
+ if (this.newTokensAt !== null) {
4568
+ this.newTokens = Math.min(this.burst, this.newTokens + (now - this.newTokensAt) / NEW_SESSION_REFILL_MS);
4569
+ }
4570
+ this.newTokensAt = now;
4571
+ if (this.newTokens < 1 - 1e-6) return false;
4572
+ this.newTokens = Math.max(0, this.newTokens - 1);
4573
+ return true;
4574
+ }
4575
+ answer(pub) {
4576
+ void this.room.send(pub, { type: "open", seq: 0, ack: 0 });
4577
+ }
4578
+ accept(pub) {
4579
+ const peer = { session: null, link: null };
4580
+ peer.link = new ReliableLink({
4581
+ clock: this.clock,
4582
+ send: (out) => void this.room.send(pub, out),
4583
+ deliver: (text) => peer.session && this.hub.receive(peer.session, text),
4584
+ onPeerClose: () => this.detach(pub, peer),
4585
+ onDead: () => this.detach(pub, peer)
4586
+ });
4587
+ this.peers.set(pub, peer);
4588
+ this.answer(pub);
4589
+ const session = this.hub.connect(
4590
+ `nostr:${pub.slice(0, 16)}`,
4591
+ (text) => {
4592
+ if (!text.startsWith('{"t":"pong"')) peer.link.send(text);
4593
+ },
4594
+ () => {
4595
+ peer.link.close();
4596
+ this.forget(pub, peer);
4597
+ }
4598
+ );
4599
+ if (peer.link.closed) return;
4600
+ if (!session) {
4601
+ peer.link.close("closed");
4602
+ this.forget(pub, peer);
4603
+ return;
4604
+ }
4605
+ peer.session = session;
4606
+ }
4607
+ detach(pub, peer) {
4608
+ peer.link.stop();
4609
+ if (peer.session) this.hub.disconnect(peer.session);
4610
+ peer.session = null;
4611
+ this.forget(pub, peer);
4612
+ }
4613
+ forget(pub, peer) {
4614
+ if (this.peers.get(pub) === peer) this.peers.delete(pub);
4615
+ this.gone.add(pub);
4616
+ if (this.gone.size > CLOSED_MEMORY) this.gone.delete(this.gone.values().next().value);
4617
+ }
4618
+ };
4619
+
4620
+ // src/direct/metrics.ts
4621
+ var Metrics = class {
4622
+ counters = /* @__PURE__ */ new Map();
4623
+ gauges = /* @__PURE__ */ new Map();
4624
+ help = /* @__PURE__ */ new Map();
4625
+ describe(name, help) {
4626
+ this.help.set(name, help);
4627
+ }
4628
+ gauge(name, help, read) {
4629
+ this.gauges.set(name, { help, read });
4630
+ }
4631
+ /** Counter name plus optional single label, e.g. inc("onelobby_path_total", "path", "relayed"). */
4632
+ inc(name, label, value, by = 1) {
4633
+ const key = keyOf(name, label, value);
4634
+ this.counters.set(key, (this.counters.get(key) ?? 0) + by);
4635
+ }
4636
+ get(name, label, value) {
4637
+ return this.counters.get(keyOf(name, label, value)) ?? 0;
4638
+ }
4639
+ render() {
4640
+ let out = "";
4641
+ for (const name of [...this.gauges.keys()].sort(ordinal)) {
4642
+ const g = this.gauges.get(name);
4643
+ out += `# HELP ${name} ${g.help}
4644
+ # TYPE ${name} gauge
4645
+ ${name} ${g.read()}
4646
+ `;
4647
+ }
4648
+ let lastBase = null;
4649
+ for (const key of [...this.counters.keys()].sort(ordinal)) {
4650
+ const br = key.indexOf("{");
4651
+ const base = br < 0 ? key : key.slice(0, br);
4652
+ if (base !== lastBase) {
4653
+ const h = this.help.get(base);
4654
+ if (h !== void 0) out += `# HELP ${base} ${h}
4655
+ `;
4656
+ out += `# TYPE ${base} counter
4657
+ `;
4658
+ lastBase = base;
4659
+ }
4660
+ out += `${key} ${this.counters.get(key)}
4661
+ `;
4662
+ }
4663
+ return out;
4664
+ }
4665
+ };
4666
+ var ordinal = (a, b) => a < b ? -1 : a > b ? 1 : 0;
4667
+ var escape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
4668
+ var keyOf = (name, label, value) => label === void 0 ? name : `${name}{${label}="${escape(value ?? "")}"}`;
4669
+
4670
+ // src/direct/sha1.ts
4671
+ var enc3 = new TextEncoder();
4672
+ function sha1(data) {
4673
+ const h = [1732584193, 4023233417, 2562383102, 271733878, 3285377520];
4674
+ const len = data.length;
4675
+ const padded = new Uint8Array((len + 8 >> 6 << 6) + 64);
4676
+ padded.set(data);
4677
+ padded[len] = 128;
4678
+ const dv = new DataView(padded.buffer);
4679
+ dv.setUint32(padded.length - 8, Math.floor(len * 8 / 4294967296));
4680
+ dv.setUint32(padded.length - 4, len * 8 >>> 0);
4681
+ const w = new Uint32Array(80);
4682
+ for (let off = 0; off < padded.length; off += 64) {
4683
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
4684
+ for (let i = 16; i < 80; i++) {
4685
+ const x = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
4686
+ w[i] = x << 1 | x >>> 31;
4687
+ }
4688
+ let [a, b, c, d, e] = h;
4689
+ for (let i = 0; i < 80; i++) {
4690
+ let f;
4691
+ let k;
4692
+ if (i < 20) {
4693
+ f = b & c | ~b & d;
4694
+ k = 1518500249;
4695
+ } else if (i < 40) {
4696
+ f = b ^ c ^ d;
4697
+ k = 1859775393;
4698
+ } else if (i < 60) {
4699
+ f = b & c | b & d | c & d;
4700
+ k = 2400959708;
4701
+ } else {
4702
+ f = b ^ c ^ d;
4703
+ k = 3395469782;
4704
+ }
4705
+ const t = (a << 5 | a >>> 27) + f + e + k + w[i] | 0;
4706
+ e = d;
4707
+ d = c;
4708
+ c = b << 30 | b >>> 2;
4709
+ b = a;
4710
+ a = t;
4711
+ }
4712
+ h[0] = h[0] + a | 0;
4713
+ h[1] = h[1] + b | 0;
4714
+ h[2] = h[2] + c | 0;
4715
+ h[3] = h[3] + d | 0;
4716
+ h[4] = h[4] + e | 0;
4717
+ }
4718
+ const out = new Uint8Array(20);
4719
+ const odv = new DataView(out.buffer);
4720
+ for (let i = 0; i < 5; i++) odv.setUint32(i * 4, h[i] >>> 0);
4721
+ return out;
4722
+ }
4723
+ function hmacSha1(key, msg) {
4724
+ const k = key.length > 64 ? sha1(key) : key;
4725
+ const ipad = new Uint8Array(64 + msg.length);
4726
+ const opad = new Uint8Array(84);
4727
+ for (let i = 0; i < 64; i++) {
4728
+ const kb = i < k.length ? k[i] : 0;
4729
+ ipad[i] = kb ^ 54;
4730
+ opad[i] = kb ^ 92;
4731
+ }
4732
+ ipad.set(msg, 64);
4733
+ opad.set(sha1(ipad), 64);
4734
+ return sha1(opad);
4735
+ }
4736
+ function toHex2(b) {
4737
+ let s = "";
4738
+ for (const x of b) s += x.toString(16).padStart(2, "0");
4739
+ return s;
4740
+ }
4741
+ function toBase642(b) {
4742
+ let s = "";
4743
+ for (const x of b) s += String.fromCharCode(x);
4744
+ return btoa(s);
4745
+ }
4746
+ function turnCredential(secret, username) {
4747
+ return toBase642(hmacSha1(enc3.encode(secret), enc3.encode(username)));
4748
+ }
4749
+ function shortHash(text) {
4750
+ return toHex2(sha1(enc3.encode(text))).slice(0, 8);
4751
+ }
4752
+ function randomHex(n) {
4753
+ return toHex2(globalThis.crypto.getRandomValues(new Uint8Array(n)));
4754
+ }
4755
+ function randomBelow(n) {
4756
+ const range = 4294967296;
4757
+ const limit = range - range % n;
4758
+ const buf2 = new Uint32Array(1);
4759
+ for (; ; ) {
4760
+ globalThis.crypto.getRandomValues(buf2);
4761
+ if (buf2[0] < limit) return buf2[0] % n;
4762
+ }
4763
+ }
4764
+
4765
+ // src/direct/hub.ts
4766
+ var PROTO2 = "lk1";
4767
+ var NAME_MAX = 24;
4768
+ var LOBBY_NAME_MAX = 48;
4769
+ var META_KEYS_MAX = 16;
4770
+ var META_LEN_MAX = 64;
4771
+ var PENDING_MAX = 8;
4772
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4773
+ var GAME_ID = /^[A-Za-z0-9._-]{1,64}$/;
4774
+ function defaultHubOptions() {
4775
+ return {
4776
+ turnUrls: [],
4777
+ turnSecret: "",
4778
+ stunUrls: ["stun:stun.l.google.com:19302"],
4779
+ region: "",
4780
+ allowedGames: [],
4781
+ gameQuotas: {},
4782
+ maxLobbies: 1e3,
4783
+ maxPeers: 1e4,
4784
+ hardMaxPlayers: 64,
4785
+ rateScale: 1,
4786
+ turnTtlSeconds: 3600,
4787
+ maxMessageBytes: 16 * 1024,
4788
+ disableIpLimits: false,
4789
+ createPerMin: 10,
4790
+ joinPerMin: 60,
4791
+ signalPerSec: 20,
4792
+ socketsPerIp: 32,
4793
+ openLobbiesPerIp: 8,
4794
+ idleTimeoutMs: 3e4,
4795
+ resumeWindowMs: 6e4,
4796
+ codeReuseMs: 24 * 3600 * 1e3,
4797
+ listLimitMax: 100,
4798
+ singleLobby: false,
4799
+ password: null,
4800
+ issueCodes: true
4801
+ };
4802
+ }
4803
+ function directHubOptions(maxPlayers, password) {
4804
+ const n = Math.min(250, Math.max(2, maxPlayers));
4805
+ return {
4806
+ ...defaultHubOptions(),
4807
+ singleLobby: true,
4808
+ issueCodes: false,
4809
+ password: password ? password : null,
4810
+ stunUrls: [],
4811
+ turnUrls: [],
4812
+ maxLobbies: 1,
4813
+ hardMaxPlayers: n,
4814
+ maxPeers: n + 8
4815
+ };
4816
+ }
4817
+ var SIGNAL_FANOUT = 8;
4818
+ var NOSTR_ADDR = "nostr:";
4819
+ var NOSTR_SIGNAL_PER_SEC = 10;
4820
+ var RateWindow = class {
4821
+ constructor(limit, windowMs) {
4822
+ this.limit = limit;
4823
+ this.windowMs = windowMs;
4824
+ }
4825
+ limit;
4826
+ windowMs;
4827
+ hits = [];
4828
+ tryHit(now) {
4829
+ this.trim(now);
4830
+ if (this.hits.length >= this.limit) return false;
4831
+ this.hits.push(now);
4832
+ return true;
4833
+ }
4834
+ retryAfterMs(now) {
4835
+ this.trim(now);
4836
+ return this.hits.length < this.limit || this.hits.length === 0 ? 0 : Math.max(1, this.windowMs - (now - this.hits[0]));
4837
+ }
4838
+ isIdle(now) {
4839
+ this.trim(now);
4840
+ return this.hits.length === 0;
4841
+ }
4842
+ trim(now) {
4843
+ while (this.hits.length > 0 && now - this.hits[0] >= this.windowMs) this.hits.shift();
4844
+ }
4845
+ };
4846
+ function sanitize(s, max, fallback = "") {
4847
+ if (!s) return fallback;
4848
+ let r = "";
4849
+ for (const ch of s) {
4850
+ const c = ch.charCodeAt(0);
4851
+ if (ch.length === 1 && (c <= 31 || c >= 127 && c <= 159 || c >= 8234 && c <= 8238 || c >= 8294 && c <= 8297)) continue;
4852
+ r += ch;
4853
+ }
4854
+ if (r.length > max) {
4855
+ r = r.slice(0, max);
4856
+ const last = r.charCodeAt(r.length - 1);
4857
+ if (last >= 55296 && last <= 56319) r = r.slice(0, -1);
4858
+ }
4859
+ return r.length === 0 ? fallback : r;
4860
+ }
4861
+ function normalizeCode2(input) {
4862
+ if (typeof input !== "string") return null;
4863
+ let out = "";
4864
+ for (const raw of input) {
4865
+ if (raw === " " || raw === "-") continue;
4866
+ let ch = raw >= "a" && raw <= "z" ? raw.toUpperCase() : raw;
4867
+ if (ch === "I" || ch === "L") ch = "1";
4868
+ else if (ch === "O") ch = "0";
4869
+ if (CROCKFORD.indexOf(ch) < 0 || out.length >= 6) return null;
4870
+ out += ch;
4871
+ }
4872
+ return out.length === 6 ? out : null;
4873
+ }
4874
+ var Session = class {
4875
+ constructor(id, ip, connId, send, close) {
4876
+ this.id = id;
4877
+ this.ip = ip;
4878
+ this.connId = connId;
4879
+ this.send = send;
4880
+ this.close = close;
4881
+ }
4882
+ id;
4883
+ ip;
4884
+ connId;
4885
+ send;
4886
+ close;
4887
+ closed = false;
4888
+ lastSeen = 0;
4889
+ helloDone = false;
4890
+ gameId = "";
4891
+ netVersion = "";
4892
+ name = "";
4893
+ lobby = null;
4894
+ member = null;
4895
+ pendingIn = null;
4896
+ pendingMember = null;
4897
+ /** Nostr joiners: the remote address of the host's WebRTC link to this session, once known (kick bans it too). */
4898
+ linkIp = null;
4899
+ /** One window per target member (a member belongs to one lobby, so a new lobby starts fresh). */
4900
+ signalRates = /* @__PURE__ */ new Map();
4901
+ /** All signals of this session, so the per-target budget does not grow without bound with the lobby. */
4902
+ signalTotal = null;
4903
+ };
4904
+ var Member = class {
4905
+ peerId = 0;
4906
+ name = "";
4907
+ joinedAt = 0;
4908
+ session = null;
4909
+ detachedAt = 0;
4910
+ token = "";
4911
+ ip = "";
4912
+ renames = null;
4913
+ };
4914
+ var HubLobby = class {
4915
+ code = "";
4916
+ gameId = "";
4917
+ netVersion = "";
4918
+ name = "";
4919
+ visibility = "public";
4920
+ mode = "lockstep";
4921
+ tickRate = 20;
4922
+ lateJoin = false;
4923
+ relayOnly = false;
4924
+ phase = "lobby";
4925
+ maxPlayers = 0;
4926
+ createdAt = 0;
4927
+ ownerIp = "";
4928
+ meta = {};
4929
+ members = [];
4930
+ pending = [];
4931
+ kicked = /* @__PURE__ */ new Set();
4932
+ hostId = 0;
4933
+ epoch = 0;
4934
+ /** Highest valid host_changed claim a member made while the host was still connected; applied when the host goes. */
4935
+ pendingClaimPeer = 0;
4936
+ pendingClaimEpoch = 0;
4937
+ nextPeerId = 1;
4938
+ find(peerId) {
4939
+ return this.members.find((m) => m.peerId === peerId);
4940
+ }
4941
+ };
4942
+ var KNOWN_TYPES = /* @__PURE__ */ new Set(["hello", "create", "join", "list", "leave", "signal", "meta", "phase", "kick", "approve", "ping", "host_changed", "path", "set_name"]);
4943
+ var str2 = (o, k) => typeof o[k] === "string" ? o[k] : null;
4944
+ var int2 = (o, k) => {
4945
+ const v = o[k];
4946
+ return typeof v === "number" && Number.isInteger(v) && Math.abs(v) < 2147483647 ? v : null;
4947
+ };
4948
+ var bool2 = (o, k) => typeof o[k] === "boolean" ? o[k] : null;
4949
+ var has = (o, k) => o[k] !== void 0 && o[k] !== null;
4950
+ var field = (name) => ({ field: name });
4951
+ var hex = (n) => randomHex(n);
4952
+ var byCode = (a, b) => a < b ? -1 : a > b ? 1 : 0;
4953
+ function readMeta(node) {
4954
+ if (node === void 0 || node === null) return { meta: {} };
4955
+ if (typeof node !== "object" || Array.isArray(node)) return { err: "meta must be an object of strings" };
4956
+ const entries = Object.entries(node);
4957
+ if (entries.length > META_KEYS_MAX) return { err: `meta allows at most ${META_KEYS_MAX} keys` };
4958
+ const meta = {};
4959
+ for (const [k, v] of entries.sort(([a], [b]) => byCode(a, b))) {
4960
+ if (typeof v !== "string") return { err: "meta values must be strings" };
4961
+ if (k.length > META_LEN_MAX || v.length > META_LEN_MAX) return { err: `meta keys and values are limited to ${META_LEN_MAX} chars` };
4962
+ meta[k] = v;
4963
+ }
4964
+ return { meta };
4965
+ }
4966
+ var SignalingHub = class {
4967
+ o;
4968
+ now;
4969
+ rand;
4970
+ log;
4971
+ lobbies = /* @__PURE__ */ new Map();
4972
+ retired = /* @__PURE__ */ new Map();
4973
+ tokens = /* @__PURE__ */ new Map();
4974
+ sessions = /* @__PURE__ */ new Map();
4975
+ gameLobbies = /* @__PURE__ */ new Map();
4976
+ ips = /* @__PURE__ */ new Map();
4977
+ nextSession = 0;
4978
+ createdOnce = false;
4979
+ members = 0;
4980
+ logSalt = hex(8);
4981
+ metrics = new Metrics();
4982
+ constructor(options = {}, deps = {}) {
4983
+ this.o = { ...defaultHubOptions(), ...options };
4984
+ this.now = deps.now ?? (() => Date.now());
4985
+ this.rand = deps.randomInt ?? ((n) => randomBelow(n));
4986
+ this.log = deps.log ?? (() => {
4987
+ });
4988
+ const m = this.metrics;
4989
+ m.gauge("onelobby_lobbies", "Live lobbies.", () => this.lobbyCount);
4990
+ m.gauge("onelobby_sockets", "Open signaling sockets.", () => this.socketCount);
4991
+ m.gauge("onelobby_lobbies_retired", "Join codes held back from reuse.", () => this.retired.size);
4992
+ m.describe("onelobby_messages_total", "Client messages received, by type.");
4993
+ m.describe("onelobby_errors_total", "Error replies sent, by code.");
4994
+ m.describe("onelobby_path_total", "ICE path reports from peers: direct vs relayed (DESIGN s.10).");
4995
+ m.describe("onelobby_connections_total", "Sockets accepted since start.");
4996
+ m.describe("onelobby_lobbies_created_total", "Lobbies created since start.");
4997
+ m.describe("onelobby_rate_limit_hits_total", "Requests refused by a rate limit, by kind.");
4998
+ }
4999
+ get lobbyCount() {
5000
+ return this.lobbies.size;
5001
+ }
5002
+ get socketCount() {
5003
+ return this.sessions.size;
5004
+ }
5005
+ get scale() {
5006
+ return Math.max(1, this.o.rateScale);
5007
+ }
5008
+ // ---------------------------------------------------------------- connection lifecycle
5009
+ /** Registers a socket. Returns null (after sending server_full and closing) when over maxPeers. */
5010
+ connect(ip, send, close) {
5011
+ const s = new Session(++this.nextSession, ip, hex(6), send, close);
5012
+ s.lastSeen = this.now();
5013
+ this.metrics.inc("onelobby_connections_total");
5014
+ if (this.sessions.size >= this.o.maxPeers) {
5015
+ this.error(s, "server_full", "server is at its peer limit");
5016
+ s.close();
5017
+ return null;
5018
+ }
5019
+ const st = this.ipOf(ip);
5020
+ if (!this.o.disableIpLimits && st.sockets >= this.o.socketsPerIp * this.scale) {
5021
+ this.rateLimited(s, "sockets", 5e3, "max_sockets");
5022
+ s.close(1008, "too_many_sockets");
5023
+ return null;
5024
+ }
5025
+ this.sessions.set(s.id, s);
5026
+ st.sockets++;
5027
+ return s;
5028
+ }
5029
+ disconnect(s) {
5030
+ if (!this.sessions.delete(s.id)) return;
5031
+ s.closed = true;
5032
+ const st = this.ips.get(s.ip);
5033
+ if (st) st.sockets--;
5034
+ if (s.pendingIn) {
5035
+ s.pendingIn.pending = s.pendingIn.pending.filter((p) => p !== s.pendingMember);
5036
+ s.pendingIn = null;
5037
+ s.pendingMember = null;
5038
+ }
5039
+ const m = s.member;
5040
+ const lobby = s.lobby;
5041
+ if (m && lobby) {
5042
+ m.session = null;
5043
+ m.detachedAt = this.now();
5044
+ s.member = null;
5045
+ s.lobby = null;
5046
+ this.logEvent("detach", lobby, s);
5047
+ this.broadcast(lobby, { t: "peer_left", peerId: m.peerId, reason: "timeout" });
5048
+ this.metrics.inc("onelobby_peers_left_total", "reason", "timeout");
5049
+ if (lobby.hostId === m.peerId) this.promoteHost(lobby, m.peerId);
5050
+ }
5051
+ }
5052
+ /**
5053
+ * The host's WebRTC link to joiner `peerId` has the remote address `ip` (selected candidate pair; the host's own
5054
+ * lobby reports it, a relayed or mDNS address must not be passed). Only Nostr sessions take it: their socket address
5055
+ * is a pubkey the joiner chose. A joiner whose link address is banned is removed as kicked; a later kick bans it.
5056
+ */
5057
+ reportLinkAddress(peerId, ip) {
5058
+ for (const lobby of this.lobbies.values()) {
5059
+ const mem = lobby.find(peerId);
5060
+ const sess = mem?.session;
5061
+ if (!mem || !sess || !sess.ip.startsWith(NOSTR_ADDR)) continue;
5062
+ sess.linkIp = ip;
5063
+ if (lobby.kicked.has(ip)) {
5064
+ this.removeMember(lobby, mem, "kicked");
5065
+ this.closeSession(sess);
5066
+ }
5067
+ return;
5068
+ }
5069
+ }
5070
+ closeSession(s) {
5071
+ s.close();
5072
+ this.disconnect(s);
5073
+ }
5074
+ // ---------------------------------------------------------------- time
5075
+ /** Call about once a second: idle timeouts, resume-window expiry, code retirement. */
5076
+ tick() {
5077
+ const now = this.now();
5078
+ for (const s of [...this.sessions.values()])
5079
+ if (now - s.lastSeen > this.o.idleTimeoutMs) {
5080
+ this.metrics.inc("onelobby_idle_timeouts_total");
5081
+ this.closeSession(s);
5082
+ }
5083
+ for (const lobby of [...this.lobbies.values()])
5084
+ for (const m of [...lobby.members]) if (m.detachedAt > 0 && now - m.detachedAt >= this.o.resumeWindowMs) this.removeMember(lobby, m, "timeout");
5085
+ for (const [code, at] of [...this.retired]) if (now - at >= this.o.codeReuseMs) this.retired.delete(code);
5086
+ for (const [key, st] of [...this.ips])
5087
+ if (st.sockets <= 0 && st.open <= 0 && st.create.isIdle(now) && st.join.isIdle(now)) this.ips.delete(key);
5088
+ }
5089
+ // ---------------------------------------------------------------- inbound
5090
+ receive(s, text) {
5091
+ if (s.closed) return;
5092
+ s.lastSeen = this.now();
5093
+ if (text.length > this.o.maxMessageBytes) {
5094
+ this.error(s, "bad_request", "message too large");
5095
+ this.closeSession(s);
5096
+ return;
5097
+ }
5098
+ let msg = null;
5099
+ try {
5100
+ const v = JSON.parse(text);
5101
+ msg = v && typeof v === "object" && !Array.isArray(v) ? v : null;
5102
+ } catch {
5103
+ msg = null;
5104
+ }
5105
+ const t = msg ? str2(msg, "t") : null;
5106
+ if (!msg || t === null) {
5107
+ this.error(s, "bad_request", "expected a JSON object with a string t", field("t"));
5108
+ return;
5109
+ }
5110
+ this.metrics.inc("onelobby_messages_total", "type", KNOWN_TYPES.has(t) ? t : "unknown");
5111
+ if (t === "hello") return this.hello(s, msg);
5112
+ if (!s.helloDone) {
5113
+ if (KNOWN_TYPES.has(t)) this.error(s, "bad_request", "send hello first");
5114
+ return;
5115
+ }
5116
+ switch (t) {
5117
+ case "create":
5118
+ return this.create(s, msg);
5119
+ case "join":
5120
+ return this.join(s, msg);
5121
+ case "list":
5122
+ return this.list(s, msg);
5123
+ case "leave":
5124
+ return this.leave(s);
5125
+ case "signal":
5126
+ return this.signal(s, msg);
5127
+ case "meta":
5128
+ return this.hostMeta(s, msg);
5129
+ case "phase":
5130
+ return this.hostPhase(s, msg);
5131
+ case "kick":
5132
+ return this.kick(s, msg);
5133
+ case "approve":
5134
+ return this.approve(s, msg);
5135
+ case "ping": {
5136
+ const n = int2(msg, "n");
5137
+ if (n === null || n < 0) return this.error(s, "bad_request", "ping needs an integer n", field("n"));
5138
+ return this.send(s, { t: "pong", n });
5139
+ }
5140
+ case "host_changed":
5141
+ return this.claimHost(s, msg);
5142
+ case "path":
5143
+ return this.pathReport(s, msg);
5144
+ case "set_name":
5145
+ return this.setName(s, msg);
5146
+ default:
5147
+ return;
5148
+ }
5149
+ }
5150
+ // ---------------------------------------------------------------- hello / welcome
5151
+ hello(s, m) {
5152
+ if (s.helloDone) return this.error(s, "bad_request", "hello already sent");
5153
+ if (str2(m, "proto") !== PROTO2) {
5154
+ this.error(s, "bad_request", "unsupported proto, expected lk1", { supported: [PROTO2] });
5155
+ this.closeSession(s);
5156
+ return;
5157
+ }
5158
+ if (has(m, "name") && str2(m, "name") === null) return this.error(s, "bad_request", "name must be a string", field("name"));
5159
+ if (has(m, "client") && (typeof m.client !== "object" || Array.isArray(m.client)))
5160
+ return this.error(s, "bad_request", "client must be an object", field("client"));
5161
+ const gameId = str2(m, "gameId");
5162
+ const netVersion = str2(m, "netVersion");
5163
+ if (gameId === null || !GAME_ID.test(gameId)) return this.error(s, "bad_request", "gameId must match [A-Za-z0-9._-]{1,64}", field("gameId"));
5164
+ if (!netVersion || netVersion.length > 32) return this.error(s, "bad_request", "netVersion must be 1-32 characters", field("netVersion"));
5165
+ if (this.o.allowedGames.length > 0 && !this.o.allowedGames.includes(gameId)) {
5166
+ this.error(s, "game_not_allowed", "this server does not host that game");
5167
+ this.closeSession(s);
5168
+ return;
5169
+ }
5170
+ s.gameId = gameId;
5171
+ s.netVersion = netVersion;
5172
+ s.name = sanitize(str2(m, "name"), NAME_MAX, "Player");
5173
+ s.helloDone = true;
5174
+ let resumed = null;
5175
+ let rl = null;
5176
+ const token = str2(m, "resumeToken");
5177
+ const entry = token !== null ? this.tokens.get(token) : void 0;
5178
+ if (entry && entry.lobby.gameId === gameId && entry.lobby.netVersion === netVersion) {
5179
+ const old = entry.member.session;
5180
+ const takeOver = old !== null && old !== void 0 && old !== s;
5181
+ const inWindow = entry.member.detachedAt > 0 && this.now() - entry.member.detachedAt < this.o.resumeWindowMs;
5182
+ if (takeOver || inWindow) {
5183
+ if (takeOver) this.closeSession(old);
5184
+ resumed = entry.member;
5185
+ rl = entry.lobby;
5186
+ }
5187
+ }
5188
+ const w = {
5189
+ t: "welcome",
5190
+ ice: this.iceList(s),
5191
+ serverTime: this.now(),
5192
+ limits: { maxMsg: this.o.maxMessageBytes, maxPlayers: this.o.hardMaxPlayers },
5193
+ features: ["set_name"]
5194
+ };
5195
+ if (resumed) w.peerId = resumed.peerId;
5196
+ this.send(s, w);
5197
+ if (resumed && rl) {
5198
+ this.tokens.delete(resumed.token);
5199
+ resumed.token = hex(16);
5200
+ this.tokens.set(resumed.token, { lobby: rl, member: resumed });
5201
+ resumed.session = s;
5202
+ resumed.detachedAt = 0;
5203
+ s.lobby = rl;
5204
+ s.member = resumed;
5205
+ this.logEvent("resume", rl, s);
5206
+ this.metrics.inc("onelobby_resumes_total");
5207
+ for (const other of rl.members) if (other !== resumed) this.sendTo(other, { t: "peer_joined", peer: this.peerObj(resumed) });
5208
+ this.send(s, this.lobbyMsg(rl, resumed));
5209
+ }
5210
+ }
5211
+ iceList(s) {
5212
+ const ice = [];
5213
+ if (this.o.stunUrls.length > 0) ice.push({ urls: [...this.o.stunUrls] });
5214
+ if (this.o.turnUrls.length > 0 && this.o.turnSecret.length > 0) {
5215
+ const expiry = Math.floor(this.now() / 1e3) + this.o.turnTtlSeconds;
5216
+ const username = `${expiry}:${s.connId}`;
5217
+ const credential = turnCredential(this.o.turnSecret, username);
5218
+ ice.push({ urls: [...this.o.turnUrls], username, credential, ttl: this.o.turnTtlSeconds });
5219
+ }
5220
+ return ice;
5221
+ }
5222
+ // ---------------------------------------------------------------- create / join / list / leave
5223
+ ipOf(ip) {
5224
+ let st = this.ips.get(ip);
5225
+ if (!st) {
5226
+ st = { create: new RateWindow(this.o.createPerMin * this.scale, 6e4), join: new RateWindow(this.o.joinPerMin * this.scale, 6e4), open: 0, sockets: 0 };
5227
+ this.ips.set(ip, st);
5228
+ }
5229
+ return st;
5230
+ }
5231
+ create(s, m) {
5232
+ if (s.lobby || s.pendingIn) return this.error(s, "bad_request", "already in a lobby");
5233
+ const ip = this.ipOf(s.ip);
5234
+ const now = this.now();
5235
+ if (!this.o.disableIpLimits) {
5236
+ if (!ip.create.tryHit(now)) return this.rateLimited(s, "create", ip.create.retryAfterMs(now), null);
5237
+ if (ip.open >= this.o.openLobbiesPerIp * this.scale) return this.rateLimited(s, "open_lobbies", 5e3, "max_lobbies");
5238
+ }
5239
+ if (this.o.singleLobby && this.createdOnce) return this.error(s, "server_full", "this host runs one lobby");
5240
+ if (this.lobbies.size >= this.o.maxLobbies || this.members >= this.o.maxPeers) return this.error(s, "server_full", "server is at its lobby limit");
5241
+ const quota = this.o.gameQuotas[s.gameId];
5242
+ if (quota !== void 0 && (this.gameLobbies.get(s.gameId) ?? 0) >= quota) return this.error(s, "server_full", "lobby quota for this game is reached");
5243
+ if (has(m, "name") && str2(m, "name") === null) return this.error(s, "bad_request", "name must be a string", field("name"));
5244
+ const vis = str2(m, "visibility") ?? "public";
5245
+ const mode = str2(m, "mode") ?? "lockstep";
5246
+ if (vis !== "public" && vis !== "unlisted" && vis !== "private") return this.error(s, "bad_request", "visibility must be public, unlisted or private", field("visibility"));
5247
+ if (mode !== "lockstep" && mode !== "raw") return this.error(s, "bad_request", "mode must be lockstep or raw", field("mode"));
5248
+ if (has(m, "maxPlayers") && int2(m, "maxPlayers") === null) return this.error(s, "bad_request", "maxPlayers must be an integer", field("maxPlayers"));
5249
+ const maxPlayers = int2(m, "maxPlayers") ?? 8;
5250
+ if (maxPlayers < 2 || maxPlayers > this.o.hardMaxPlayers) return this.error(s, "bad_request", `maxPlayers must be 2-${this.o.hardMaxPlayers}`, field("maxPlayers"));
5251
+ if (has(m, "tickRate") && int2(m, "tickRate") === null) return this.error(s, "bad_request", "tickRate must be an integer", field("tickRate"));
5252
+ const tick = int2(m, "tickRate") ?? 20;
5253
+ if (tick < 1 || tick > 255) return this.error(s, "bad_request", "tickRate must be 1-255", field("tickRate"));
5254
+ if (has(m, "lateJoin") && bool2(m, "lateJoin") === null) return this.error(s, "bad_request", "lateJoin must be a boolean", field("lateJoin"));
5255
+ if (has(m, "relayOnly") && bool2(m, "relayOnly") === null) return this.error(s, "bad_request", "relayOnly must be a boolean", field("relayOnly"));
5256
+ const meta = readMeta(m.meta);
5257
+ if ("err" in meta) return this.error(s, "bad_request", meta.err, field("meta"));
5258
+ const code = this.newCode();
5259
+ if (code === null) return this.error(s, "server_full", "could not allocate a join code");
5260
+ const lobby = new HubLobby();
5261
+ Object.assign(lobby, {
5262
+ code,
5263
+ gameId: s.gameId,
5264
+ netVersion: s.netVersion,
5265
+ name: sanitize(str2(m, "name"), LOBBY_NAME_MAX, `${s.name}'s lobby`),
5266
+ visibility: vis,
5267
+ mode,
5268
+ tickRate: tick,
5269
+ lateJoin: bool2(m, "lateJoin") ?? false,
5270
+ relayOnly: bool2(m, "relayOnly") ?? false,
5271
+ maxPlayers,
5272
+ createdAt: now,
5273
+ ownerIp: s.ip,
5274
+ meta: meta.meta
5275
+ });
5276
+ this.lobbies.set(code, lobby);
5277
+ this.createdOnce = true;
5278
+ this.gameLobbies.set(s.gameId, (this.gameLobbies.get(s.gameId) ?? 0) + 1);
5279
+ ip.open++;
5280
+ this.metrics.inc("onelobby_lobbies_created_total");
5281
+ const host = this.addMember(lobby, s, null);
5282
+ lobby.hostId = host.peerId;
5283
+ this.logEvent("create", lobby, s);
5284
+ this.send(s, this.lobbyMsg(lobby, host));
5285
+ }
5286
+ join(s, m) {
5287
+ if (s.lobby || s.pendingIn) return this.error(s, "bad_request", "already in a lobby");
5288
+ const ip = this.ipOf(s.ip);
5289
+ const now = this.now();
5290
+ if (!this.o.disableIpLimits && !ip.join.tryHit(now)) return this.rateLimited(s, "join", ip.join.retryAfterMs(now), null);
5291
+ let lobby;
5292
+ if (this.o.singleLobby && !has(m, "code")) {
5293
+ lobby = this.lobbies.values().next().value;
5294
+ if (!lobby || lobby.gameId !== s.gameId) return this.error(s, "unknown_code", "this host has no open lobby for that game");
5295
+ } else {
5296
+ const code = normalizeCode2(m.code);
5297
+ if (code === null) return this.error(s, "bad_request", "malformed join code", field("code"));
5298
+ lobby = this.lobbies.get(code);
5299
+ if (!lobby || lobby.gameId !== s.gameId) return this.error(s, "unknown_code", "no lobby with that code");
5300
+ }
5301
+ if (this.o.password !== null && str2(m, "password") !== this.o.password) return this.error(s, "bad_password", "wrong password for this lobby");
5302
+ if (lobby.netVersion !== s.netVersion)
5303
+ return this.error(s, "version_mismatch", `host is on ${lobby.netVersion}, you are on ${s.netVersion}`, { hostNetVersion: lobby.netVersion, yourNetVersion: s.netVersion });
5304
+ if (lobby.kicked.has(s.ip)) return this.error(s, "kicked", "you were removed from this lobby");
5305
+ if (lobby.phase === "in_game" && !lobby.lateJoin) return this.error(s, "in_game", "the game has already started");
5306
+ if (lobby.members.length >= lobby.maxPlayers) return this.error(s, "lobby_full", "lobby is full");
5307
+ if (this.members >= this.o.maxPeers) return this.error(s, "server_full", "server is at its peer limit");
5308
+ if (lobby.visibility === "private") {
5309
+ if (lobby.pending.length >= PENDING_MAX) return this.error(s, "lobby_full", "too many pending requests");
5310
+ const pm = new Member();
5311
+ pm.peerId = lobby.nextPeerId++;
5312
+ pm.name = s.name;
5313
+ pm.session = s;
5314
+ pm.ip = s.ip;
5315
+ lobby.pending.push(pm);
5316
+ s.pendingIn = lobby;
5317
+ s.pendingMember = pm;
5318
+ const hs = lobby.find(lobby.hostId)?.session;
5319
+ if (hs) this.send(hs, { t: "join_request", peer: this.peerObj(pm) });
5320
+ this.logEvent("join_request", lobby, s);
5321
+ return;
5322
+ }
5323
+ this.admit(lobby, s, null);
5324
+ }
5325
+ admit(lobby, s, pending) {
5326
+ const joiner = this.addMember(lobby, s, pending);
5327
+ this.logEvent("join", lobby, s);
5328
+ for (const other of lobby.members) if (other !== joiner) this.sendTo(other, { t: "peer_joined", peer: this.peerObj(joiner) });
5329
+ if (!lobby.find(lobby.hostId)?.session) this.promoteHost(lobby, lobby.hostId);
5330
+ this.send(s, this.lobbyMsg(lobby, joiner));
5331
+ }
5332
+ addMember(lobby, s, reuse) {
5333
+ const mem = reuse ?? new Member();
5334
+ if (!reuse) mem.peerId = lobby.nextPeerId++;
5335
+ mem.name = s.name;
5336
+ mem.session = s;
5337
+ mem.ip = s.ip;
5338
+ mem.joinedAt = this.now();
5339
+ mem.detachedAt = 0;
5340
+ mem.token = hex(16);
5341
+ lobby.members.push(mem);
5342
+ this.members++;
5343
+ this.tokens.set(mem.token, { lobby, member: mem });
5344
+ s.lobby = lobby;
5345
+ s.member = mem;
5346
+ s.pendingIn = null;
5347
+ s.pendingMember = null;
5348
+ return mem;
5349
+ }
5350
+ list(s, m) {
5351
+ const vis = str2(m, "visibility");
5352
+ if (!this.o.issueCodes) return this.send(s, { t: "lobbies", items: [] });
5353
+ if (vis !== "public") return this.error(s, "bad_request", "only public lobbies can be listed", field("visibility"));
5354
+ let limit = 50;
5355
+ if (has(m, "limit")) {
5356
+ const l = int2(m, "limit");
5357
+ if (l === null || l < 1 || l > this.o.listLimitMax) return this.error(s, "bad_request", `limit must be 1-${this.o.listLimitMax}`, field("limit"));
5358
+ limit = l;
5359
+ }
5360
+ let offset = 0;
5361
+ if (has(m, "cursor")) {
5362
+ const c = str2(m, "cursor");
5363
+ if (c === null || !/^\d+$/.test(c)) return this.error(s, "bad_request", "bad cursor", field("cursor"));
5364
+ offset = Number(c);
5365
+ }
5366
+ let filter = null;
5367
+ if (has(m, "meta")) {
5368
+ if (typeof m.meta !== "object" || Array.isArray(m.meta)) return this.error(s, "bad_request", "meta must be an object", field("meta"));
5369
+ filter = {};
5370
+ for (const [k, v] of Object.entries(m.meta)) {
5371
+ if (typeof v !== "string") return this.error(s, "bad_request", "meta values must be strings", field("meta"));
5372
+ filter[k] = v;
5373
+ }
5374
+ }
5375
+ const all = [...this.lobbies.values()].filter((l) => l.gameId === s.gameId && l.netVersion === s.netVersion && l.visibility === "public" && (l.phase !== "in_game" || l.lateJoin) && (filter === null || Object.entries(filter).every(([k, v]) => l.meta[k] === v))).sort((a, b) => a.createdAt - b.createdAt || byCode(a.code, b.code));
5376
+ const page = all.slice(offset, offset + limit);
5377
+ const items = page.map((l) => ({
5378
+ code: l.code,
5379
+ gameId: l.gameId,
5380
+ netVersion: l.netVersion,
5381
+ name: l.name,
5382
+ hostName: l.find(l.hostId)?.name ?? "",
5383
+ players: l.members.length,
5384
+ maxPlayers: l.maxPlayers,
5385
+ phase: l.phase,
5386
+ meta: { ...l.meta },
5387
+ region: this.o.region,
5388
+ createdAt: l.createdAt
5389
+ }));
5390
+ const reply = { t: "lobbies", items };
5391
+ if (offset + page.length < all.length) reply.cursor = String(offset + page.length);
5392
+ this.send(s, reply);
5393
+ }
5394
+ leave(s) {
5395
+ if (s.pendingIn) {
5396
+ s.pendingIn.pending = s.pendingIn.pending.filter((p) => p !== s.pendingMember);
5397
+ s.pendingIn = null;
5398
+ s.pendingMember = null;
5399
+ return;
5400
+ }
5401
+ if (!s.lobby || !s.member) return;
5402
+ this.removeMember(s.lobby, s.member, "left");
5403
+ }
5404
+ // ---------------------------------------------------------------- relay + host-only
5405
+ signal(s, m) {
5406
+ const lobby = s.lobby;
5407
+ const me = s.member;
5408
+ if (!lobby || !me) return this.error(s, "bad_request", "not in a lobby");
5409
+ const to = int2(m, "to");
5410
+ const target = to === null ? void 0 : lobby.find(to);
5411
+ if (!target || !target.session || target === me) return this.error(s, "bad_request", "unknown signal target", field("to"));
5412
+ if (this.o.singleLobby && me.peerId !== lobby.hostId && target.peerId !== lobby.hostId)
5413
+ return this.error(s, "bad_request", "signals go to or from the host only", field("to"));
5414
+ const now = this.now();
5415
+ const rate = this.signalRate(s, target, now);
5416
+ if (!rate.tryHit(now)) return this.rateLimited(s, "signal", rate.retryAfterMs(now), null);
5417
+ const total = s.signalTotal ??= new RateWindow(this.o.signalPerSec * SIGNAL_FANOUT * this.scale, 1e3);
5418
+ if (!total.tryHit(now)) return this.rateLimited(s, "signal", total.retryAfterMs(now), null);
5419
+ if (!has(m, "sdp") && !has(m, "ice")) return this.error(s, "bad_request", "signal needs sdp or ice", field("sdp"));
5420
+ const o = { t: "signal", from: me.peerId };
5421
+ if (has(m, "sdp")) o.sdp = m.sdp;
5422
+ if (has(m, "ice")) o.ice = m.ice;
5423
+ this.send(target.session, o);
5424
+ this.metrics.inc("onelobby_signals_relayed_total");
5425
+ }
5426
+ /**
5427
+ * spec/signaling.md rate limits: `signal` is counted per sender and target, so a host answering several joiners at once
5428
+ * gets the budget for each, and per sender in total (SIGNAL_FANOUT targets' worth).
5429
+ */
5430
+ signalRate(s, to, now) {
5431
+ let w = s.signalRates.get(to);
5432
+ if (!w) {
5433
+ for (const [k, v] of s.signalRates) if (v.isIdle(now)) s.signalRates.delete(k);
5434
+ w = new RateWindow((to.session?.ip.startsWith(NOSTR_ADDR) ? NOSTR_SIGNAL_PER_SEC : this.o.signalPerSec) * this.scale, 1e3);
5435
+ s.signalRates.set(to, w);
5436
+ }
5437
+ return w;
5438
+ }
5439
+ requireHost(s) {
5440
+ if (!s.lobby || !s.member) {
5441
+ this.error(s, "bad_request", "not in a lobby");
5442
+ return null;
5443
+ }
5444
+ if (s.lobby.hostId !== s.member.peerId) {
5445
+ this.error(s, "not_host", "only the host can do that");
5446
+ return null;
5447
+ }
5448
+ return s.lobby;
5449
+ }
5450
+ hostMeta(s, m) {
5451
+ const lobby = this.requireHost(s);
5452
+ if (!lobby) return;
5453
+ const meta = readMeta(m.meta);
5454
+ if ("err" in meta || !has(m, "meta")) return this.error(s, "bad_request", "err" in meta ? meta.err : "meta is required", field("meta"));
5455
+ lobby.meta = meta.meta;
5456
+ this.broadcastLobby(lobby);
5457
+ }
5458
+ setName(s, m) {
5459
+ if (!s.lobby || !s.member) return this.error(s, "bad_request", "not in a lobby");
5460
+ const name = typeof m.name === "string" ? sanitize(m.name, NAME_MAX) : "";
5461
+ if (!name) return this.error(s, "bad_request", "name must be a non-empty string", field("name"));
5462
+ if (name === s.member.name) return;
5463
+ const now = this.now();
5464
+ const rate = s.member.renames ??= new RateWindow(this.scale, 1e3);
5465
+ if (!rate.tryHit(now)) return this.rateLimited(s, "set_name", rate.retryAfterMs(now), null);
5466
+ s.member.name = name;
5467
+ s.name = name;
5468
+ this.broadcastLobby(s.lobby);
5469
+ }
5470
+ hostPhase(s, m) {
5471
+ const lobby = this.requireHost(s);
5472
+ if (!lobby) return;
5473
+ const ph = str2(m, "phase");
5474
+ if (ph !== "in_game" && ph !== "lobby") return this.error(s, "bad_request", "phase must be in_game or lobby", field("phase"));
5475
+ lobby.phase = ph;
5476
+ this.logEvent(`phase:${ph}`, lobby, s);
5477
+ this.broadcastLobby(lobby);
5478
+ }
5479
+ kick(s, m) {
5480
+ const lobby = this.requireHost(s);
5481
+ if (!lobby) return;
5482
+ const id = int2(m, "peerId");
5483
+ const target = id === null ? void 0 : lobby.find(id);
5484
+ if (id === null || id === s.member.peerId || !target) return this.error(s, "bad_request", "unknown peerId", field("peerId"));
5485
+ const ban = bool2(m, "ban");
5486
+ if (has(m, "ban") && ban === null) return this.error(s, "bad_request", "ban must be a boolean", field("ban"));
5487
+ const code = int2(m, "closeCode");
5488
+ if (has(m, "closeCode") && (code === null || !(code === 1e3 || code >= 3e3 && code <= 4999)))
5489
+ return this.error(s, "bad_request", "closeCode must be 1000 or 3000-4999", field("closeCode"));
5490
+ const closeReason = str2(m, "closeReason");
5491
+ if (has(m, "closeReason") && (closeReason === null || closeReason.length > 120))
5492
+ return this.error(s, "bad_request", "closeReason must be a string of at most 120 characters", field("closeReason"));
5493
+ const data = {};
5494
+ if (ban === false) data.ban = false;
5495
+ if (code !== null) data.closeCode = code;
5496
+ if (closeReason !== null) data.closeReason = closeReason;
5497
+ if (ban !== false) {
5498
+ lobby.kicked.add(target.ip);
5499
+ const link = target.session?.linkIp;
5500
+ if (link && target.ip.startsWith(NOSTR_ADDR)) lobby.kicked.add(link);
5501
+ }
5502
+ this.removeMember(lobby, target, ban === false ? "disconnected" : "kicked", Object.keys(data).length ? data : void 0);
5503
+ }
5504
+ approve(s, m) {
5505
+ const lobby = this.requireHost(s);
5506
+ if (!lobby) return;
5507
+ const id = int2(m, "peerId");
5508
+ const pm = id === null ? void 0 : lobby.pending.find((p) => p.peerId === id);
5509
+ const js = pm?.session;
5510
+ if (!pm || !js) return this.error(s, "bad_request", "no such pending request", field("peerId"));
5511
+ if (has(m, "ok") && bool2(m, "ok") === null) return this.error(s, "bad_request", "ok must be a boolean", field("ok"));
5512
+ lobby.pending = lobby.pending.filter((p) => p !== pm);
5513
+ js.pendingIn = null;
5514
+ js.pendingMember = null;
5515
+ if (bool2(m, "ok") !== true) return this.error(js, "kicked", "the host declined your request", { reason: "denied" });
5516
+ if (lobby.members.length >= lobby.maxPlayers) return this.error(js, "lobby_full", "lobby is full");
5517
+ if (lobby.phase === "in_game" && !lobby.lateJoin) return this.error(js, "in_game", "the game has already started");
5518
+ this.admit(lobby, js, pm);
5519
+ }
5520
+ /** Peer-side migration (s.9) tells the server: the new host announces itself with a higher epoch. */
5521
+ claimHost(s, m) {
5522
+ const lobby = s.lobby;
5523
+ const me = s.member;
5524
+ if (!lobby || !me) return;
5525
+ const host = int2(m, "hostId");
5526
+ const epoch = int2(m, "epoch");
5527
+ if (host !== me.peerId || epoch === null || epoch <= lobby.epoch) return;
5528
+ if (this.o.singleLobby) return;
5529
+ if (lobby.hostId !== me.peerId && lobby.find(lobby.hostId)?.session) {
5530
+ if (epoch > lobby.pendingClaimEpoch) {
5531
+ lobby.pendingClaimPeer = me.peerId;
5532
+ lobby.pendingClaimEpoch = epoch;
5533
+ }
5534
+ return;
5535
+ }
5536
+ lobby.hostId = me.peerId;
5537
+ lobby.epoch = epoch;
5538
+ lobby.pendingClaimPeer = lobby.pendingClaimEpoch = 0;
5539
+ this.broadcast(lobby, { t: "host_changed", hostId: lobby.hostId, epoch: lobby.epoch });
5540
+ this.metrics.inc("onelobby_host_changes_total");
5541
+ this.logEvent("host_claim", lobby, s);
5542
+ }
5543
+ pathReport(s, m) {
5544
+ const p = str2(m, "path");
5545
+ if (p === "relayed" || p === "direct") this.metrics.inc("onelobby_path_total", "path", p);
5546
+ else this.error(s, "bad_request", "path must be relayed or direct", field("path"));
5547
+ }
5548
+ // ---------------------------------------------------------------- membership changes
5549
+ /** Removes a member for good. A member that is already detached was announced as left, so nothing is sent for it. */
5550
+ removeMember(lobby, mem, reason, kickData) {
5551
+ const i = lobby.members.indexOf(mem);
5552
+ if (i < 0) return;
5553
+ lobby.members.splice(i, 1);
5554
+ this.members--;
5555
+ this.tokens.delete(mem.token);
5556
+ const ms = mem.session;
5557
+ const wasConnected = mem.detachedAt === 0;
5558
+ if (ms) {
5559
+ ms.lobby = null;
5560
+ ms.member = null;
5561
+ }
5562
+ if (wasConnected) {
5563
+ this.broadcast(lobby, { t: "peer_left", peerId: mem.peerId, reason });
5564
+ this.metrics.inc("onelobby_peers_left_total", "reason", reason);
5565
+ if ((reason === "kicked" || reason === "disconnected") && ms)
5566
+ this.error(ms, "kicked", reason === "kicked" ? "the host removed you from the lobby" : "the host disconnected you", kickData);
5567
+ }
5568
+ this.logEvent(reason, lobby, ms);
5569
+ if (lobby.members.length === 0) return this.closeLobby(lobby);
5570
+ if (lobby.hostId === mem.peerId) this.promoteHost(lobby, mem.peerId);
5571
+ }
5572
+ /** Server mirror of the peer-side rule (s.9): new host is the lowest peerId still connected; epoch +1. */
5573
+ promoteHost(lobby, leavingId) {
5574
+ const claimant = lobby.pendingClaimEpoch > lobby.epoch && lobby.pendingClaimPeer !== leavingId ? lobby.members.find((x) => x.peerId === lobby.pendingClaimPeer && x.session) : void 0;
5575
+ const claimEpoch = lobby.pendingClaimEpoch;
5576
+ lobby.pendingClaimPeer = lobby.pendingClaimEpoch = 0;
5577
+ let next = claimant ?? lobby.members.filter((x) => x.session && x.peerId !== leavingId).sort((a, b) => a.peerId - b.peerId)[0];
5578
+ if (!next) {
5579
+ if (lobby.find(leavingId)) return;
5580
+ next = [...lobby.members].sort((a, b) => a.peerId - b.peerId)[0];
5581
+ if (!next) return;
5582
+ }
5583
+ lobby.hostId = next.peerId;
5584
+ if (claimant) lobby.epoch = claimEpoch;
5585
+ else lobby.epoch++;
5586
+ this.broadcast(lobby, { t: "host_changed", hostId: next.peerId, epoch: lobby.epoch });
5587
+ this.metrics.inc("onelobby_host_changes_total");
5588
+ const hs = next.session;
5589
+ if (hs) for (const pm of lobby.pending) this.send(hs, { t: "join_request", peer: this.peerObj(pm) });
5590
+ this.logEvent("host_changed", lobby, next.session);
5591
+ }
5592
+ closeLobby(lobby) {
5593
+ this.lobbies.delete(lobby.code);
5594
+ this.retired.set(lobby.code, this.now());
5595
+ const n = this.gameLobbies.get(lobby.gameId);
5596
+ if (n !== void 0) {
5597
+ if (n <= 1) this.gameLobbies.delete(lobby.gameId);
5598
+ else this.gameLobbies.set(lobby.gameId, n - 1);
5599
+ }
5600
+ const ip = this.ips.get(lobby.ownerIp);
5601
+ if (ip && ip.open > 0) ip.open--;
5602
+ for (const pm of [...lobby.pending]) {
5603
+ const ps = pm.session;
5604
+ if (ps) {
5605
+ ps.pendingIn = null;
5606
+ ps.pendingMember = null;
5607
+ this.error(ps, "unknown_code", "the lobby closed");
5608
+ }
5609
+ }
5610
+ lobby.pending = [];
5611
+ this.logEvent("close", lobby, null);
5612
+ }
5613
+ newCode() {
5614
+ for (let i = 0; i < 100; i++) {
5615
+ let c = "";
5616
+ for (let k = 0; k < 6; k++) c += CROCKFORD[this.rand(32)];
5617
+ if (!this.lobbies.has(c) && !this.retired.has(c)) return c;
5618
+ }
5619
+ return null;
5620
+ }
5621
+ // ---------------------------------------------------------------- output helpers
5622
+ lobbyMsg(l, you) {
5623
+ const peers = l.members.filter((x) => x.detachedAt === 0).sort((a, b) => a.peerId - b.peerId).map((p) => ({ peerId: p.peerId, name: p.name, joinedAt: p.joinedAt }));
5624
+ return {
5625
+ t: "lobby",
5626
+ code: this.o.issueCodes ? l.code : "",
5627
+ gameId: l.gameId,
5628
+ netVersion: l.netVersion,
5629
+ hostId: l.hostId,
5630
+ you: you.peerId,
5631
+ mode: l.mode,
5632
+ tickRate: l.tickRate,
5633
+ lateJoin: l.lateJoin,
5634
+ phase: l.phase,
5635
+ meta: { ...l.meta },
5636
+ peers,
5637
+ resumeToken: you.token,
5638
+ name: l.name,
5639
+ maxPlayers: l.maxPlayers,
5640
+ visibility: l.visibility,
5641
+ relayOnly: l.relayOnly
5642
+ };
5643
+ }
5644
+ broadcastLobby(l) {
5645
+ for (const m of l.members) if (m.session) this.sendTo(m, this.lobbyMsg(l, m));
5646
+ }
5647
+ broadcast(l, o) {
5648
+ for (const m of l.members) this.sendTo(m, o);
5649
+ }
5650
+ peerObj(m) {
5651
+ return { peerId: m.peerId, name: m.name };
5652
+ }
5653
+ send(s, o) {
5654
+ if (!s.closed) s.send(JSON.stringify(o));
5655
+ }
5656
+ sendTo(m, o) {
5657
+ if (m.session) this.send(m.session, o);
5658
+ }
5659
+ error(s, code, msg, data) {
5660
+ this.metrics.inc("onelobby_errors_total", "code", code);
5661
+ const o = { t: "error", code, msg };
5662
+ if (data !== void 0) o.data = data;
5663
+ s.send(JSON.stringify(o));
5664
+ }
5665
+ rateLimited(s, kind, retryAfterMs, reason) {
5666
+ this.metrics.inc("onelobby_rate_limit_hits_total", "kind", kind);
5667
+ this.log(`rate_limit kind=${kind} ip=${this.hashIp(s.ip)}`);
5668
+ const data = { retryAfterMs };
5669
+ if (reason !== null) data.reason = reason;
5670
+ this.error(s, "rate_limited", `too many ${kind} requests`, data);
5671
+ }
5672
+ logEvent(ev, l, s) {
5673
+ this.log(`lobby ${ev} code=${l.code} game=${l.gameId} players=${l.members.length} ip=${s ? this.hashIp(s.ip) : "-"}`);
5674
+ }
5675
+ hashIp(ip) {
5676
+ return shortHash(this.logSalt + ip);
5677
+ }
5678
+ };
5679
+
5680
+ // src/direct/hub-local.ts
5681
+ var LocalHubSocket = class _LocalHubSocket {
5682
+ constructor(hub) {
5683
+ this.hub = hub;
5684
+ this.session = hub.connect(
5685
+ _LocalHubSocket.ADDRESS,
5686
+ (text) => queueMicrotask(() => this.readyState === 1 && this.onmessage?.({ data: text })),
5687
+ () => this.finish()
5688
+ );
5689
+ queueMicrotask(() => {
5690
+ if (!this.session) {
5691
+ this.finish();
5692
+ return;
5693
+ }
5694
+ this.readyState = 1;
5695
+ this.onopen?.();
5696
+ });
5697
+ }
5698
+ hub;
5699
+ /** Address the hub sees for the host itself (rate limits and kicks are per address). */
5700
+ static ADDRESS = "local";
5701
+ readyState = 0;
5702
+ onopen = null;
5703
+ onmessage = null;
5704
+ onclose = null;
5705
+ onerror = null;
5706
+ session;
5707
+ send(data) {
5708
+ if (this.readyState === 1 && this.session) this.hub.receive(this.session, data);
5709
+ }
5710
+ close() {
5711
+ const s = this.session;
5712
+ const wasOpen = this.readyState === 1;
5713
+ this.finish();
5714
+ if (wasOpen && s) this.hub.disconnect(s);
5715
+ }
5716
+ finish() {
5717
+ if (this.readyState === 3) return;
5718
+ this.readyState = 3;
5719
+ queueMicrotask(() => this.onclose?.());
5720
+ }
5721
+ };
5722
+
5723
+ // src/direct/browser-host.ts
5724
+ function formatBrowserHostLog(code, relays) {
5725
+ return `OneLobby: browser host (no port); code ${code} (Nostr); nostr ${relays} relays`;
5726
+ }
5727
+ async function hostNostr(kit, opts = {}) {
5728
+ const bp = kit.blueprint;
5729
+ if (bp && !bp.hosting.nostr) {
5730
+ throw new OneLobbyError("nostr_disabled", "this game has Nostr signaling turned off (hosting.nostr is false), and a browser host has no other way to be reached");
5731
+ }
5732
+ const defined = Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== void 0));
5733
+ const o = bp ? {
5734
+ maxPlayers: bp.lobby.maxPlayers,
5735
+ lateJoin: bp.lobby.lateJoin,
5736
+ meta: { ...bp.lobby.meta },
5737
+ ...bp.session.rateLimit ? { rateLimit: { ...bp.session.rateLimit } } : {},
5738
+ ...defined,
5739
+ nostr: { ...defined.nostr, relays: defined.nostr?.relays ?? [...bp.hosting.nostrRelays] }
5740
+ } : defined;
5741
+ const maxPlayers = o.maxPlayers ?? 4;
5742
+ const relays = o.nostr?.relays && o.nostr.relays.length > 0 ? o.nostr.relays : [...DEFAULT_NOSTR_RELAYS];
5743
+ const stunUrls = o.nostr?.stun ?? [...DEFAULT_NOSTR_STUN];
5744
+ const hub = new SignalingHub({ ...directHubOptions(maxPlayers, o.password), stunUrls });
5745
+ const ticker = setInterval(() => hub.tick(), 1e3);
5746
+ ticker.unref?.();
5747
+ const code = encodeRoomCode();
5748
+ const env = kit.nostrHostEnv();
5749
+ let bridge;
5750
+ const teardown = () => {
5751
+ clearInterval(ticker);
5752
+ bridge?.close();
5753
+ };
5754
+ try {
5755
+ try {
5756
+ bridge = await NostrHostBridge.start(hub, { code, relays, connect: env.connect, clock: env.clock, wall: env.wall, maxPlayers });
5757
+ } catch (e) {
5758
+ throw new OneLobbyError("direct_unreachable", `cannot reach any Nostr relay (${e instanceof Error ? e.message : String(e)}); a browser host has no other way to be reached`);
5759
+ }
5760
+ if (!await bridge.settle(o.relayTimeoutMs ?? HOST_NOSTR_FIRST_RELAY_MS)) {
5761
+ throw new OneLobbyError("direct_unreachable", `no Nostr relay reachable (tried ${bridge.relaysTotal}); a browser host has no IP fallback, check the connection or pass other relays`);
5762
+ }
5763
+ const name = o.name ?? `${kit.options.displayName || "Player"}'s game`;
5764
+ const h = { name, maxPlayers, lateJoin: o.lateJoin ?? true, meta: o.meta, rateLimit: o.rateLimit };
5765
+ const lobby = await kit.hostDirectWith(h, { socketFactory: () => new LocalHubSocket(hub), wrapLinks: (inner) => inner });
5766
+ lobby.linkAddressSink = (peerId, ip) => hub.reportLinkAddress(peerId, ip);
5767
+ lobby.on("sessionEnded", teardown);
5768
+ lobby.joinCode = code;
5769
+ lobby.joinCodeKind = "nostr";
5770
+ lobby.directCode = "";
5771
+ lobby.directCodeKind = "none";
5772
+ lobby.portMapping = "off";
5773
+ lobby.directHostLog = formatBrowserHostLog(code, `${bridge.relaysConnected}/${bridge.relaysTotal}`);
5774
+ watchVisibility(lobby, o);
5775
+ if (o.log !== false) (o.log ?? console.log)(lobby.directHostLog);
5776
+ return lobby;
5777
+ } catch (e) {
5778
+ teardown();
5779
+ throw e;
5780
+ }
5781
+ }
5782
+ function watchVisibility(lobby, o) {
5783
+ const doc = o.document !== void 0 ? o.document : globalThis.document;
5784
+ if (!doc || typeof doc.addEventListener !== "function") return;
5785
+ const warn = o.warn === false ? null : o.warn ?? ((s) => console.warn(s));
5786
+ const check2 = () => {
5787
+ const hidden = doc.visibilityState === "hidden";
5788
+ const changed = hidden !== lobby.hostHidden;
5789
+ lobby.setHostHidden(hidden);
5790
+ if (changed && hidden) warn?.("OneLobby: the host tab is hidden; the browser slows its timers, so players may lag or drop. Keep this tab visible while hosting.");
5791
+ };
5792
+ doc.addEventListener("visibilitychange", check2);
5793
+ lobby.on("sessionEnded", () => doc.removeEventListener("visibilitychange", check2));
5794
+ check2();
5795
+ }
5796
+
3254
5797
  // src/onelobby.ts
3255
5798
  var LIB_NAME = "onelobby";
3256
- var LIB_VERSION = "0.5.0";
5799
+ var LIB_VERSION = "0.7.0";
3257
5800
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
3258
5801
  function hasMic() {
3259
5802
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -3281,6 +5824,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3281
5824
  createMic;
3282
5825
  createSink;
3283
5826
  storage;
5827
+ nostrConnectFn;
3284
5828
  /** The validated, frozen onelobby.json this kit was built from (`OneLobby.fromConfig`); undefined for `new OneLobby`. */
3285
5829
  blueprint;
3286
5830
  /**
@@ -3355,6 +5899,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3355
5899
  this.createMic = mic !== void 0 ? mic : hasMic() ? () => new WorkletMic() : null;
3356
5900
  const sink = pick("createSink");
3357
5901
  this.createSink = sink !== void 0 ? sink : typeof AudioContext !== "undefined" ? () => new WebAudioSink() : null;
5902
+ this.nostrConnectFn = a.nostrConnect;
3358
5903
  this.storage = o.rememberSeat === false ? null : a.storage !== void 0 ? a.storage : browserStorage();
3359
5904
  }
3360
5905
  // ---- remembered seat -------------------------------------------------------------------------------------------
@@ -3461,6 +6006,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3461
6006
  signaling: sig,
3462
6007
  state,
3463
6008
  ice: welcome.ice ?? [],
6009
+ features: welcome.features ?? [],
3464
6010
  links: direct?.links ?? this.linkFactory,
3465
6011
  direct: !!direct,
3466
6012
  clock: this.clock,
@@ -3533,18 +6079,18 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3533
6079
  }
3534
6080
  }
3535
6081
  /**
3536
- * Join a direct-IP host (docs/direct-ip.md): a join code from the host's screen (`6B01-RGFE-P2B`, any case, spaces
6082
+ * Join a direct-IP host (docs/design/direct-ip.md): a join code from the host's screen (`6B01-RGFE-P2B`, any case, spaces
3537
6083
  * or hyphens) or, advanced, an address (`ip`, `ip:port`, `[v6]:port`). No lobby server is involved: the code is
3538
6084
  * the host's address. A mistyped code fails at once with `bad_code`; nobody listening is `direct_unreachable`.
3539
6085
  * The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
3540
6086
  */
3541
6087
  async joinDirect(codeOrAddress, opts = {}) {
3542
- const { addr, links } = this.directTarget(codeOrAddress, opts);
3543
- const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
6088
+ const t = this.directTarget(codeOrAddress, opts);
6089
+ const { sig, welcome } = await this.connectDirect(t, opts);
3544
6090
  try {
3545
6091
  const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3546
- const lobby = this.makeLobby(sig, welcome, state, { links });
3547
- lobby.joinCode = joinedWith(addr);
6092
+ const lobby = this.makeLobby(sig, this.withStun(welcome, t, opts), state, { links: t.links });
6093
+ lobby.joinCode = t.joinedWith;
3548
6094
  return lobby;
3549
6095
  } catch (e) {
3550
6096
  sig.close();
@@ -3557,24 +6103,55 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3557
6103
  * same peer id comes back. Rejects with `invalid_token` when the seat is gone: offer `joinDirect` then.
3558
6104
  */
3559
6105
  async rejoinDirect(codeOrAddress, resumeToken, opts = {}) {
3560
- const { addr, links } = this.directTarget(codeOrAddress, opts);
3561
- const lobby = await this.resumeAt(resumeToken, addr.url, opts.connectTimeoutMs ?? 5e3, { links });
3562
- lobby.joinCode = joinedWith(addr);
6106
+ const t = this.directTarget(codeOrAddress, opts);
6107
+ const lobby = await this.resumeAt(resumeToken, t.url, t.nostr ? 0 : opts.connectTimeoutMs ?? 5e3, { links: t.links }, t.nostr ? this.nostrFactory(t, opts) : void 0, (w) => this.withStun(w, t, opts));
6108
+ lobby.joinCode = t.joinedWith;
3563
6109
  return lobby;
3564
6110
  }
6111
+ nostrFactory(t, opts) {
6112
+ const relays = opts.nostrRelays ?? (this.blueprint ? [...this.blueprint.hosting.nostrRelays] : void 0);
6113
+ return () => new NostrSocket({ code: t.nostr, relays: relays && relays.length > 0 ? relays : void 0, connect: this.nostrConnectFn, clock: this.clock, wall: this.wall });
6114
+ }
6115
+ /** Opens the signaling socket of a direct target: TCP to the host's address, or Nostr relays for a version 1 code. */
6116
+ async connectDirect(t, opts) {
6117
+ if (!t.nostr) return this.connect(t.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
6118
+ let sock = null;
6119
+ const factory = this.nostrFactory(t, opts);
6120
+ try {
6121
+ return await this.connect(t.url, (u) => sock = factory(u), 0);
6122
+ } catch (e) {
6123
+ const why = sock?.failure;
6124
+ if (e instanceof OneLobbyError && e.code === "connect_failed") throw new OneLobbyError("direct_unreachable", why || "no answer through the Nostr relays");
6125
+ throw e;
6126
+ }
6127
+ }
6128
+ /** A Nostr joiner needs STUN; take the host's list (it comes in `welcome`), else the public defaults. */
6129
+ withStun(welcome, t, opts) {
6130
+ if (!t.nostr || welcome.ice && welcome.ice.length > 0) return welcome;
6131
+ const urls = opts.stun ?? [...DEFAULT_NOSTR_STUN];
6132
+ return urls.length > 0 ? { ...welcome, ice: [{ urls }] } : welcome;
6133
+ }
3565
6134
  directTarget(codeOrAddress, opts) {
3566
6135
  let input = codeOrAddress.trim();
3567
6136
  if (looksLikeJoinCode(input)) {
3568
6137
  const d = decodeJoinCode(input);
3569
6138
  if ("error" in d)
3570
6139
  throw new OneLobbyError(d.error, d.error === "unknown_version" ? `"${input}" is a join code from a newer version of the game` : `"${input}" is not a valid join code: check for a typo`);
6140
+ if ("room" in d) {
6141
+ const shown = encodeRoomCodeFor(d.room);
6142
+ return { nostr: shown, url: `nostr:${shown}`, joinedWith: shown, links: this.linkFactory };
6143
+ }
3571
6144
  input = `${d.ip}:${d.port}`;
3572
6145
  }
3573
6146
  const addr = parseDirectAddress(input, opts.defaultPort ?? DEFAULT_DIRECT_PORT);
3574
6147
  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)`);
3575
6148
  const inner = this.linkFactory;
3576
6149
  const links = { create: (o) => inner.create({ ...o, iceServers: [], extraRemoteCandidates: [addr.candidate] }) };
3577
- return { addr, links };
6150
+ return { nostr: null, url: addr.url, joinedWith: joinedWith(addr), links };
6151
+ }
6152
+ /** @internal onelobby/direct's Nostr host: the relay seam and the kit's clocks. */
6153
+ nostrHostEnv() {
6154
+ return { connect: this.nostrConnectFn, clock: this.clock, wall: this.wall };
3578
6155
  }
3579
6156
  /** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
3580
6157
  async hostDirectWith(h, hooks) {
@@ -3599,17 +6176,27 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3599
6176
  throw e;
3600
6177
  }
3601
6178
  }
6179
+ /**
6180
+ * Host a direct lobby from this browser tab (or any runtime with WebSocket and WebRTC): no Node process, no port.
6181
+ * The hub runs in the page and joiners find it through Nostr relays with `lobby.joinCode` (a dc1 version 1 code,
6182
+ * `joinCodeKind` "nostr"); they use `kit.joinDirect(code)`. Rejects with `direct_unreachable` when no relay answers
6183
+ * within 10 s (a browser has no IP fallback) and `nostr_disabled` when the blueprint has `hosting.nostr` false.
6184
+ * Keep the tab visible: hidden tabs are throttled (`lobby.hostHidden`, event `hostHiddenChanged`).
6185
+ */
6186
+ hostNostr(opts = {}) {
6187
+ return hostNostr(this, opts);
6188
+ }
3602
6189
  async rejoin(resumeToken) {
3603
6190
  return this.resumeAt(resumeToken, this.options.lobbyServer, 0);
3604
6191
  }
3605
- async resumeAt(resumeToken, url, timeoutMs, direct) {
3606
- const sig = await this.open(url, this.socketFactory, timeoutMs);
6192
+ async resumeAt(resumeToken, url, timeoutMs, direct, factory, tweakWelcome) {
6193
+ const sig = await this.open(url, factory ?? this.socketFactory, timeoutMs);
3607
6194
  try {
3608
6195
  const lobbyP = sig.expect("lobby");
3609
6196
  const welcomeP = sig.request(this.helloMsg(resumeToken), "welcome");
3610
6197
  lobbyP.catch(() => {
3611
6198
  });
3612
- const welcome = await welcomeP;
6199
+ const welcome = tweakWelcome ? tweakWelcome(await welcomeP) : await welcomeP;
3613
6200
  if (welcome.peerId === void 0) throw new OneLobbyError("invalid_token", "resume token unknown or expired");
3614
6201
  const state = await lobbyP;
3615
6202
  return this.makeLobby(sig, welcome, state, direct);
@@ -3648,6 +6235,13 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3648
6235
  return out;
3649
6236
  }
3650
6237
  function guardLobby(lobby, bp) {
6238
+ const rename = lobby.rename.bind(lobby);
6239
+ lobby.rename = async (name) => {
6240
+ if (bp.players.identity === "anonymous") throw new OneLobbyError("identity_anonymous", "this game uses anonymous players: no display names");
6241
+ if (String(name ?? "").trim().length > bp.players.maxNameLength)
6242
+ throw new BlueprintError("displayName", "length", `must be at most ${bp.players.maxNameLength} characters`);
6243
+ return rename(name);
6244
+ };
3651
6245
  lobby.voice.mode = bp.chat.voiceMode;
3652
6246
  if (!bp.chat.voice) {
3653
6247
  const voice = lobby.voice;
@@ -3663,6 +6257,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3663
6257
  });
3664
6258
  }
3665
6259
  }
6260
+ lobby.pause.mode = bp.session.pause;
3666
6261
  if (!bp.chat.text) {
3667
6262
  lobby.chat.send = () => {
3668
6263
  throw new OneLobbyError("chat_disabled", "this game has text chat turned off (chat.text is false)");
@@ -3692,16 +6287,22 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3692
6287
  DirectUnreachable: "direct_unreachable",
3693
6288
  BadAddress: "bad_address",
3694
6289
  PortInUse: "port_in_use",
6290
+ Unsupported: "unsupported",
3695
6291
  ConnectFailed: "connect_failed",
3696
6292
  Closed: "closed",
3697
6293
  ServerLost: "server_lost",
3698
6294
  InvalidToken: "invalid_token",
3699
6295
  NoServer: "no_server",
6296
+ // from the shared pause (lobby.pause)
6297
+ NotInGame: "not_in_game",
6298
+ NotAllowed: "not_allowed",
3700
6299
  // from a blueprint (onelobby.json)
3701
6300
  BadConfig: "bad_config",
3702
6301
  VoiceDisabled: "voice_disabled",
3703
6302
  ChatDisabled: "chat_disabled",
6303
+ PauseDisabled: "pause_disabled",
3704
6304
  ListingDisabled: "listing_disabled",
6305
+ NostrDisabled: "nostr_disabled",
3705
6306
  IdentityAnonymous: "identity_anonymous"
3706
6307
  };
3707
6308
  var TEXT = {
@@ -3727,9 +6328,15 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3727
6328
  ice_failed: { message: "Couldn't connect to the other players.", hint: "Some networks (school, work, public Wi-Fi) block game connections. Try another network or a phone hotspot." },
3728
6329
  no_server: { message: "This game has no lobby server address yet.", hint: "Enter one in the multiplayer settings, or host from your own computer with a join code." },
3729
6330
  timeout: { message: "The connection took too long.", hint: "Check your internet connection, then try again." },
6331
+ unsupported: { message: "The host's game is too old for that.", hint: "Ask the host to update the game." },
3730
6332
  voice_disabled: { message: "Voice chat is turned off in this game." },
3731
6333
  chat_disabled: { message: "Text chat is turned off in this game." },
6334
+ pause_disabled: { message: "Pausing is turned off in this game." },
6335
+ not_in_game: { message: "You can only pause while a game is running.", hint: "Start the game first." },
6336
+ not_allowed: { message: "You can't do that right now.", hint: "The player who paused, or the host, can resume." },
3732
6337
  listing_disabled: { message: "This game has no public lobby list.", hint: "Join with a code or a link from the host." },
6338
+ direct_unreachable: { message: "Couldn't reach the host.", hint: "Check the code, and that the host is still online. A host in a browser needs its tab open and visible." },
6339
+ nostr_disabled: { message: "This game can't be hosted from a browser.", hint: "Host from the desktop version, or let the game's owner know." },
3733
6340
  identity_anonymous: { message: "This game doesn't use player names.", hint: "Everyone shows up as Player 1, Player 2, and so on." },
3734
6341
  bad_config: { message: "The game's multiplayer settings file is not valid.", hint: "Let the game's owner know; the details are in the error message." },
3735
6342
  left: { message: "You left the lobby." },
@@ -3787,6 +6394,16 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3787
6394
  u.searchParams.set(param, formatCode(norm).replace(" ", "-"));
3788
6395
  return u.toString();
3789
6396
  }
6397
+ async function directJoinInfo(base) {
6398
+ const b = base ?? currentHref();
6399
+ if (!b) return null;
6400
+ try {
6401
+ const res = await fetch(new URL("/joininfo", b), { cache: "no-store" });
6402
+ return res.ok ? await res.json() : null;
6403
+ } catch {
6404
+ return null;
6405
+ }
6406
+ }
3790
6407
  function codeFromUrl(url, param = "join") {
3791
6408
  const href = url ?? currentHref();
3792
6409
  if (!href) return null;
@@ -3800,14 +6417,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3800
6417
  return v ? normalizeCode(v) : null;
3801
6418
  }
3802
6419
 
3803
- // src/deprecated.ts
3804
- var Keepalive = OneLobby;
3805
- var KeepaliveError = OneLobbyError;
3806
-
3807
6420
  // src/ui/controller.ts
3808
- var enc3 = new TextEncoder();
6421
+ var enc4 = new TextEncoder();
3809
6422
  var dec3 = new TextDecoder();
3810
- var toBytes = (w) => enc3.encode(JSON.stringify(w));
6423
+ var toBytes = (w) => enc4.encode(JSON.stringify(w));
3811
6424
  var SharedState = class {
3812
6425
  constructor(c) {
3813
6426
  this.c = c;