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