onelobby 0.4.1 → 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 +244 -0
  3. package/dist/blueprint.d.ts +170 -0
  4. package/dist/blueprint.js +286 -0
  5. package/dist/blueprint.js.map +1 -0
  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 +37 -4
  22. package/dist/direct/index.js +162 -21
  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 +28 -0
  36. package/dist/friendly.js +40 -0
  37. package/dist/friendly.js.map +1 -1
  38. package/dist/index.d.ts +11 -3
  39. package/dist/index.js +8 -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 +54 -4
  85. package/dist/onelobby.global.js +2932 -49
  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 +158 -15
  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 +4 -2
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.4.1 | MIT license */
1
+ /*! onelobby 0.6.0 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -17,13 +17,16 @@ 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 = {};
24
24
  __export(global_exports, {
25
+ BlueprintError: () => BlueprintError,
25
26
  CHAT_MAX_BYTES: () => CHAT_MAX_BYTES,
26
27
  Chat: () => Chat,
28
+ DEFAULT_NOSTR_RELAYS: () => DEFAULT_NOSTR_RELAYS,
29
+ DEFAULT_NOSTR_STUN: () => DEFAULT_NOSTR_STUN,
27
30
  DEFAULT_READY_TIMEOUT_MS: () => DEFAULT_READY_TIMEOUT_MS,
28
31
  ErrorCodes: () => ErrorCodes,
29
32
  Keepalive: () => Keepalive,
@@ -37,17 +40,23 @@ var OneLobby = (() => {
37
40
  MIN_PEER_TIMEOUT_MS: () => MIN_PEER_TIMEOUT_MS,
38
41
  OneLobby: () => OneLobby,
39
42
  OneLobbyError: () => OneLobbyError,
43
+ Pause: () => Pause,
40
44
  SharedState: () => SharedState,
41
45
  Voice: () => Voice,
46
+ checkNostrRelays: () => checkNostrRelays,
42
47
  closeCodeFor: () => closeCodeFor,
43
48
  codeFromUrl: () => codeFromUrl,
44
49
  decodeJoinCode: () => decodeJoinCode,
50
+ defineBlueprint: () => defineBlueprint,
45
51
  defineLobbyElement: () => defineLobbyElement,
46
52
  describeError: () => describeError,
53
+ directJoinInfo: () => directJoinInfo,
54
+ encodeRoomCode: () => encodeRoomCode,
47
55
  formatCode: () => formatCode,
48
56
  lobbyServerUrl: () => lobbyServerUrl,
49
57
  looksLikeJoinCode: () => looksLikeJoinCode,
50
58
  normalizeCode: () => normalizeCode,
59
+ parseBlueprint: () => parseBlueprint,
51
60
  shareUrl: () => shareUrl,
52
61
  webCodecsOpusAvailable: () => webCodecsOpusAvailable,
53
62
  webRtcLinkFactoryFor: () => webRtcLinkFactoryFor
@@ -156,7 +165,10 @@ var OneLobby = (() => {
156
165
  voice: 80,
157
166
  chat: 96,
158
167
  chat_history: 97,
159
- chat_reject: 98
168
+ chat_reject: 98,
169
+ pause_request: 112,
170
+ pause_state: 113,
171
+ pause_refuse: 114
160
172
  };
161
173
  var MODE_LOCKSTEP = 1;
162
174
  var MODE_RAW = 2;
@@ -227,9 +239,9 @@ var OneLobby = (() => {
227
239
  }
228
240
  };
229
241
  var Reader = class {
230
- constructor(buf) {
231
- this.buf = buf;
232
- 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);
233
245
  }
234
246
  buf;
235
247
  dv;
@@ -377,6 +389,18 @@ var OneLobby = (() => {
377
389
  w.u32(msg.id);
378
390
  w.u8(msg.reason);
379
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;
380
404
  }
381
405
  const out = w.done();
382
406
  if (!allowOversize && out.length > MAX_FRAME_BYTES) throw new CodecError("too_long", `${out.length} > ${MAX_FRAME_BYTES}`);
@@ -385,11 +409,11 @@ var OneLobby = (() => {
385
409
  var TYPE_BY_ID = new Map(
386
410
  Object.keys(MsgType).map((k) => [MsgType[k], k])
387
411
  );
388
- function decodeMessage(buf, reassembled = false) {
412
+ function decodeMessage(buf2, reassembled = false) {
389
413
  const max = reassembled ? MAX_REASSEMBLED_BYTES : MAX_FRAME_BYTES;
390
- if (buf.length > max) throw new CodecError("too_long", `${buf.length} > ${max}`);
391
- if (buf.length < HEADER_BYTES) throw new CodecError("too_short", "header");
392
- 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);
393
417
  const type = TYPE_BY_ID.get(r.u8());
394
418
  const route = r.u16();
395
419
  if (!type) return null;
@@ -472,14 +496,25 @@ var OneLobby = (() => {
472
496
  case "chat_reject":
473
497
  msg = { type, id: r.u32(), reason: r.u8() };
474
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;
475
508
  }
476
- 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`);
477
510
  return { route, msg };
478
511
  }
479
512
  var CHAT_REJECT = { rate: 1, length: 2, mutedByHost: 3 };
480
513
  function chatRejectName(code) {
481
514
  return code === 1 ? "rate" : code === 2 ? "length" : code === 3 ? "muted_by_host" : "unknown";
482
515
  }
516
+ var PAUSE_FLAG_PAUSER_LEFT = 1;
517
+ var PAUSE_REFUSE = { notInGame: 1, rateLimited: 2, notAllowed: 3 };
483
518
 
484
519
  // src/chat.ts
485
520
  var CHAT_MAX_BYTES = 256;
@@ -764,6 +799,7 @@ var OneLobby = (() => {
764
799
  cancelTicker = null;
765
800
  baseTime = 0;
766
801
  emitted = 0;
802
+ held = false;
767
803
  ownHashes = /* @__PURE__ */ new Map();
768
804
  peerHashes = /* @__PURE__ */ new Map();
769
805
  desyncs = /* @__PURE__ */ new Map();
@@ -869,6 +905,23 @@ var OneLobby = (() => {
869
905
  this._started = true;
870
906
  this.emit("started");
871
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
+ }
872
925
  stop() {
873
926
  this.cancelTicker?.();
874
927
  this.cancelTicker = null;
@@ -884,7 +937,7 @@ var OneLobby = (() => {
884
937
  }
885
938
  // ---- host: frame emission ---------------------------------------------------------------------------------------
886
939
  pump(interval) {
887
- if (!this.hosting) return;
940
+ if (!this.hosting || this.held) return;
888
941
  const now = this.hub.clock.now();
889
942
  let due = Math.floor((now - this.baseTime + 1) / interval) - this.emitted;
890
943
  if (due > 10) {
@@ -1121,7 +1174,7 @@ var OneLobby = (() => {
1121
1174
  }
1122
1175
  /** True when a started joiner has heard no frames for 3 s: the Lobby then runs the host-loss rule (s.9). */
1123
1176
  hostSilent() {
1124
- return !this.hosting && this._started && this.msSinceFrame > HOST_SILENCE_MS;
1177
+ return !this.hosting && !this.held && this._started && this.msSinceFrame > HOST_SILENCE_MS;
1125
1178
  }
1126
1179
  /** Reset the silence timer, e.g. after the link to a new host opened. */
1127
1180
  touch() {
@@ -1287,6 +1340,138 @@ var OneLobby = (() => {
1287
1340
  return new OneLobbyError(String(m.code ?? "error"), m.msg === void 0 ? void 0 : String(m.msg), m.data);
1288
1341
  }
1289
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
+
1290
1475
  // src/voice-core.ts
1291
1476
  var SAMPLE_RATE = 48e3;
1292
1477
  var FRAME_MS = 20;
@@ -1807,6 +1992,7 @@ var OneLobby = (() => {
1807
1992
  // src/lobby.ts
1808
1993
  var MIN_PEER_TIMEOUT_MS = 6e3;
1809
1994
  var KICK_LINGER_MS = 3e3;
1995
+ var HELD_OFFER_MS = 2e4;
1810
1996
  function seatName(p) {
1811
1997
  const n = typeof p.name === "string" ? p.name.trim() : "";
1812
1998
  if (n && n !== "Player") return p;
@@ -1823,31 +2009,61 @@ var OneLobby = (() => {
1823
2009
  tickRate;
1824
2010
  lockstep;
1825
2011
  chat;
2012
+ /** The shared co-op pause (docs/recipes/pause.md). */
2013
+ pause;
1826
2014
  voice;
1827
2015
  localPeerId;
1828
2016
  hostPeerId;
1829
2017
  phase;
1830
2018
  meta;
2019
+ /** The lobby's name as the server keeps it (host()'s `name`, sanitised); "" if the server sends none. */
2020
+ name;
1831
2021
  resumeToken;
1832
2022
  /** Last measured round trip to the host in ms (joiners), or 0. */
1833
2023
  rtt = 0;
1834
2024
  /**
1835
- * 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
1836
2026
  * s.2a). Show it with joinCodeKind. Empty when the machine has no IPv4 address, and on joiners.
1837
2027
  */
1838
2028
  joinCode = "";
1839
- /** "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
+ */
1840
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";
1841
2041
  /** Direct-IP host only: this machine's addresses with the port, for an "Advanced" fold-out. */
1842
2042
  directAddresses = [];
1843
2043
  /** Direct-IP host only: how the router port-open went ("UPnP", "no UPnP gateway answered; ...", "off"). */
1844
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
+ }
1845
2059
  signaling;
1846
2060
  cfg;
1847
2061
  roster = /* @__PURE__ */ new Map();
1848
2062
  links = /* @__PURE__ */ new Map();
1849
2063
  /** ICE from a peer that has no link yet: its candidates can overtake its offer (node-datachannel). */
1850
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();
1851
2067
  connected = /* @__PURE__ */ new Set();
1852
2068
  // host: peers that said hi
1853
2069
  hiSentTo = /* @__PURE__ */ new Set();
@@ -1872,6 +2088,8 @@ var OneLobby = (() => {
1872
2088
  lastHeard = /* @__PURE__ */ new Map();
1873
2089
  /** Host: links of kicked / disconnected peers, kept open briefly (removePeer `linger`). */
1874
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();
1875
2093
  /** Host: per-peer fixed window for rateLimit. */
1876
2094
  rate = /* @__PURE__ */ new Map();
1877
2095
  /**
@@ -1901,6 +2119,7 @@ var OneLobby = (() => {
1901
2119
  this.hostPeerId = st.hostId;
1902
2120
  this.phase = st.phase;
1903
2121
  this.meta = { ...st.meta };
2122
+ this.name = st.name ?? "";
1904
2123
  this.resumeToken = st.resumeToken;
1905
2124
  for (const p of st.peers) this.roster.set(p.peerId, seatName(p));
1906
2125
  this.ready.catch(() => {
@@ -1934,6 +2153,11 @@ var OneLobby = (() => {
1934
2153
  this.lockstep = new Lockstep(this.hub, { hashEveryTicks: cfg.hashEveryTicks });
1935
2154
  this.chat = new Chat(this.hub, cfg.wall, (id) => this.voice.isMutedPeer(id));
1936
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
+ });
1937
2161
  this.lockstep.on("started", () => this.setPhase("in_game"));
1938
2162
  this.lockstep.on("error", (e) => this.reportError(e));
1939
2163
  this.signaling.onMessage = (m) => this.onSignaling(m);
@@ -2138,6 +2362,8 @@ var OneLobby = (() => {
2138
2362
  this.teardown("ice_failed");
2139
2363
  }
2140
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;
2141
2367
  makeLink(peerId, relayOnly = this.cfg.relayOnly) {
2142
2368
  const old = this.links.get(peerId);
2143
2369
  if (old) {
@@ -2152,6 +2378,7 @@ var OneLobby = (() => {
2152
2378
  link.onClose = (reason) => this.onLinkClosed(peerId, link, reason);
2153
2379
  link.onMessage = (ch, bytes) => this.onData(peerId, ch, bytes);
2154
2380
  link.onPath = (path) => this.signaling.send({ t: "path", path });
2381
+ link.onRemoteAddress = (ip) => this.linkAddressSink?.(peerId, ip);
2155
2382
  return link;
2156
2383
  }
2157
2384
  /** Host side: offer to a joiner. */
@@ -2201,6 +2428,7 @@ var OneLobby = (() => {
2201
2428
  return;
2202
2429
  }
2203
2430
  if (!d) return;
2431
+ if (!this.isHost && from !== this.hostPeerId && d.msg.type !== "host_claim") return;
2204
2432
  if (!reassembled) this.lastHeard.set(from, this.cfg.clock.now());
2205
2433
  const { msg, route } = d;
2206
2434
  switch (msg.type) {
@@ -2242,6 +2470,11 @@ var OneLobby = (() => {
2242
2470
  case "chat_reject":
2243
2471
  this.chat.handle(from, route, msg);
2244
2472
  break;
2473
+ case "pause_request":
2474
+ case "pause_state":
2475
+ case "pause_refuse":
2476
+ this.pause.handle(from, msg);
2477
+ break;
2245
2478
  case "voice":
2246
2479
  if (channel === "voice") this.voice.handle(from, route, msg);
2247
2480
  break;
@@ -2261,6 +2494,8 @@ var OneLobby = (() => {
2261
2494
  }
2262
2495
  onHi(from, lastTick) {
2263
2496
  this.connected.add(from);
2497
+ this.notReady.get(from)?.();
2498
+ this.notReady.delete(from);
2264
2499
  this.sendTo(from, {
2265
2500
  type: "welcome_dc",
2266
2501
  // the tick the snapshot will carry (the game's applied tick), so welcome_dc.tick == snapshot.tick even when
@@ -2271,6 +2506,7 @@ var OneLobby = (() => {
2271
2506
  hostId: this.localPeerId
2272
2507
  }, 0);
2273
2508
  this.chat.sendHistory(from);
2509
+ this.pause.sendState(from);
2274
2510
  if (this.mode === "lockstep") this.lockstep.onPeerHi(from, lastTick);
2275
2511
  this.emit("peerReady", this.roster.get(from) ?? { peerId: from, name: "" });
2276
2512
  this.flush(from);
@@ -2309,6 +2545,11 @@ var OneLobby = (() => {
2309
2545
  if (hostId === this.hostPeerId) return;
2310
2546
  this.hostPeerId = hostId;
2311
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
+ }
2312
2553
  }
2313
2554
  /** The host is gone (link closed, no frames for 3 s, or the server said so): run the s.9 rule. */
2314
2555
  handleHostLost(reason) {
@@ -2333,6 +2574,15 @@ var OneLobby = (() => {
2333
2574
  }
2334
2575
  const candidate = pickNewHost([...this.roster.keys(), this.localPeerId], failed) ?? this.localPeerId;
2335
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
+ }
2336
2586
  }
2337
2587
  becomeHost() {
2338
2588
  this.epoch = this.epoch + 1 & 65535;
@@ -2342,6 +2592,7 @@ var OneLobby = (() => {
2342
2592
  this.hiSentTo.clear();
2343
2593
  this.lastHeard.clear();
2344
2594
  this.lockstep.becomeHost();
2595
+ this.pause.onBecameHost();
2345
2596
  this.emit("hostChanged", this.localPeerId);
2346
2597
  if (this.serverUp) this.signaling.send({ t: "host_changed", hostId: this.localPeerId, epoch: this.epoch });
2347
2598
  for (const p of this.roster.keys()) {
@@ -2349,6 +2600,7 @@ var OneLobby = (() => {
2349
2600
  }
2350
2601
  }
2351
2602
  onHostClaim(from, epoch, hostId) {
2603
+ if (hostId !== from) return;
2352
2604
  const mine = { epoch: this.epoch, hostId: this.hostPeerId };
2353
2605
  const theirs = { epoch, hostId };
2354
2606
  if (hostId === this.hostPeerId && epoch === this.epoch && !this.isHost) {
@@ -2382,7 +2634,10 @@ var OneLobby = (() => {
2382
2634
  const peer = seatName(m.peer);
2383
2635
  this.roster.set(peer.peerId, peer);
2384
2636
  this.emit("peerJoined", peer);
2385
- if (this.isHost) this.offerTo(peer.peerId);
2637
+ if (this.isHost) {
2638
+ this.watchReady(peer.peerId);
2639
+ this.offerTo(peer.peerId);
2640
+ }
2386
2641
  break;
2387
2642
  }
2388
2643
  case "peer_left":
@@ -2411,6 +2666,7 @@ var OneLobby = (() => {
2411
2666
  case "lobby": {
2412
2667
  const st = m;
2413
2668
  this.meta = { ...st.meta };
2669
+ if (st.name) this.name = st.name;
2414
2670
  if (st.resumeToken && st.resumeToken !== this.resumeToken) {
2415
2671
  this.resumeToken = st.resumeToken;
2416
2672
  this.seatSink?.(this.resumeToken);
@@ -2447,16 +2703,15 @@ var OneLobby = (() => {
2447
2703
  if (s.sdp) payload.sdp = s.sdp;
2448
2704
  if (s.ice) payload.ice = s.ice;
2449
2705
  if (s.sdp?.type === "offer") {
2450
- link = this.makeLink(from);
2451
- const early = this.earlyIce.get(from) ?? [];
2452
- this.earlyIce.delete(from);
2453
- const done = link.handleSignal(payload);
2454
- for (const e of early) void link.handleSignal(e);
2455
- 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);
2456
2710
  return;
2457
2711
  }
2458
2712
  if (!link) {
2459
2713
  if (!payload.ice || this.ended || !this.roster.has(from)) return;
2714
+ if (this.cfg.direct && from !== this.hostPeerId) return;
2460
2715
  const q = this.earlyIce.get(from) ?? [];
2461
2716
  if (q.length < 64) q.push(payload);
2462
2717
  this.earlyIce.set(from, q);
@@ -2464,6 +2719,28 @@ var OneLobby = (() => {
2464
2719
  }
2465
2720
  await link.handleSignal(payload);
2466
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
+ }
2467
2744
  /**
2468
2745
  * `linger` (host kick / disconnect): stop using the peer's link at once but leave it open until that peer closes it
2469
2746
  * or KICK_LINGER_MS pass. The hub tells the peer over its WebSocket; closing the data link first would make it look
@@ -2477,6 +2754,12 @@ var OneLobby = (() => {
2477
2754
  }
2478
2755
  if (peer) this.roster.delete(peerId);
2479
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
+ }
2480
2763
  const link = this.links.get(peerId);
2481
2764
  if (link && peerId !== this.hostPeerId) {
2482
2765
  link.onMessage = null;
@@ -2504,6 +2787,7 @@ var OneLobby = (() => {
2504
2787
  this.lockstep.onPeerLeft(peerId);
2505
2788
  this.chat.onPeerLeft(peerId);
2506
2789
  this.voice.onPeerLeft(peerId);
2790
+ this.pause.onPeerLeft(peerId);
2507
2791
  if (peer) this.emit("peerLeft", peer, reason, close);
2508
2792
  if (peerId === this.hostPeerId && !this.isHost) {
2509
2793
  this.handleHostLost(reason);
@@ -2518,6 +2802,7 @@ var OneLobby = (() => {
2518
2802
  if (p !== "lobby" && p !== "in_game") return;
2519
2803
  if (this.phase === p) return;
2520
2804
  this.phase = p;
2805
+ if (p === "lobby") this.pause.onLobbyPhase();
2521
2806
  if (p === "in_game" && !this.lockstep.started) this.lockstep.markStarted();
2522
2807
  this.emit("phase", p);
2523
2808
  }
@@ -2563,10 +2848,26 @@ var OneLobby = (() => {
2563
2848
  if (this.ended || this.isHost) return;
2564
2849
  this.sendTo(this.hostPeerId, { type: "ping", n: ++this.pingN, t: this.cfg.clock.now() >>> 0 }, 0, "fast");
2565
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
+ }
2566
2865
  teardown(reason, close) {
2567
2866
  if (this.ended) return;
2568
2867
  this.ended = true;
2569
2868
  this.cancelReadyTimer?.();
2869
+ for (const cancel of this.notReady.values()) cancel();
2870
+ this.notReady.clear();
2570
2871
  if (!this.isReady) {
2571
2872
  const code = reason === "ice_failed" || reason === "kicked" ? reason : "closed";
2572
2873
  this.failReady(new OneLobbyError(code, `the lobby ended before connecting (${reason})`));
@@ -2582,6 +2883,7 @@ var OneLobby = (() => {
2582
2883
  }
2583
2884
  this.links.clear();
2584
2885
  this.earlyIce.clear();
2886
+ this.heldOffers.clear();
2585
2887
  this.lingering.clear();
2586
2888
  this.signaling.onClose = null;
2587
2889
  this.signaling.onMessage = null;
@@ -2605,7 +2907,7 @@ var OneLobby = (() => {
2605
2907
  var webCodecsOpus = {
2606
2908
  createEncoder(opts, onFrame) {
2607
2909
  let ts = 0;
2608
- const enc4 = new AudioEncoder({
2910
+ const enc5 = new AudioEncoder({
2609
2911
  output: (chunk) => {
2610
2912
  const out = new Uint8Array(chunk.byteLength);
2611
2913
  chunk.copyTo(out);
@@ -2613,7 +2915,7 @@ var OneLobby = (() => {
2613
2915
  },
2614
2916
  error: (e) => console.error("onelobby: AudioEncoder", e)
2615
2917
  });
2616
- enc4.configure({
2918
+ enc5.configure({
2617
2919
  codec: "opus",
2618
2920
  sampleRate: SAMPLE_RATE,
2619
2921
  numberOfChannels: 1,
@@ -2631,11 +2933,11 @@ var OneLobby = (() => {
2631
2933
  data: pcm
2632
2934
  });
2633
2935
  ts += 2e4;
2634
- enc4.encode(data);
2936
+ enc5.encode(data);
2635
2937
  data.close();
2636
2938
  },
2637
2939
  close() {
2638
- if (enc4.state !== "closed") enc4.close();
2940
+ if (enc5.state !== "closed") enc5.close();
2639
2941
  }
2640
2942
  };
2641
2943
  },
@@ -2720,10 +3022,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2720
3022
  }
2721
3023
  play(peerId, pcm, gain) {
2722
3024
  if (pcm.length === 0) return;
2723
- const buf = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
2724
- buf.copyToChannel(pcm, 0);
3025
+ const buf2 = this.ctx.createBuffer(1, pcm.length, SAMPLE_RATE);
3026
+ buf2.copyToChannel(pcm, 0);
2725
3027
  const src = this.ctx.createBufferSource();
2726
- src.buffer = buf;
3028
+ src.buffer = buf2;
2727
3029
  const g = this.ctx.createGain();
2728
3030
  g.gain.value = gain;
2729
3031
  src.connect(g).connect(this.ctx.destination);
@@ -2752,6 +3054,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2752
3054
  onClose = null;
2753
3055
  onMessage = null;
2754
3056
  onPath = null;
3057
+ onRemoteAddress = null;
2755
3058
  pc;
2756
3059
  channels = {};
2757
3060
  pendingIce = [];
@@ -2874,6 +3177,8 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2874
3177
  const l = pair && byId.get(pair.localCandidateId);
2875
3178
  const r = pair && byId.get(pair.remoteCandidateId);
2876
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, ""));
2877
3182
  this.onPath?.(l.candidateType === "relay" || r.candidateType === "relay" ? "relayed" : "direct");
2878
3183
  return;
2879
3184
  }
@@ -2932,7 +3237,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2932
3237
  const ip = parseIPv4(ipv4);
2933
3238
  if (ip === null) throw new Error(`not an IPv4 address: ${ipv4}`);
2934
3239
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`bad port ${port}`);
2935
- const data = BigInt(ip) << 16n | BigInt(port);
3240
+ return encodeData(BigInt(ip) << 16n | BigInt(port));
3241
+ }
3242
+ function encodeData(data) {
2936
3243
  let v = data << 5n | BigInt(crc5(data, 50));
2937
3244
  let s = "";
2938
3245
  for (let i = 0; i < JOIN_CODE_LENGTH; i++) {
@@ -2941,6 +3248,15 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2941
3248
  }
2942
3249
  return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8)}`;
2943
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
+ }
2944
3260
  function normalizeJoinCode(input) {
2945
3261
  const s = input.replace(/[ \-]/g, "").replace(/[a-z]/g, (c) => c.toUpperCase()).replace(/[IL]/g, "1").replace(/O/g, "0");
2946
3262
  if (s.length !== JOIN_CODE_LENGTH) return null;
@@ -2954,7 +3270,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2954
3270
  for (const c of s) v = v << 5n | BigInt(CROCKFORD_ALPHABET.indexOf(c));
2955
3271
  const data = v >> 5n;
2956
3272
  if (crc5(data, 50) !== Number(v & 31n)) return { error: "bad_code" };
2957
- 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" };
2958
3276
  const port = Number(data & 0xffffn);
2959
3277
  if (port === 0) return { error: "bad_code" };
2960
3278
  const a = Number(data >> 16n & 0xffffffffn);
@@ -2964,6 +3282,847 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2964
3282
  return !/[.:]/.test(input) && normalizeJoinCode(input) !== null;
2965
3283
  }
2966
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
+
2967
4126
  // src/direct-address.ts
2968
4127
  var DEFAULT_DIRECT_PORT = 47810;
2969
4128
  var IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
@@ -3014,9 +4173,1576 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3014
4173
  return { host, port, isIPv6: v6, display, url: `ws://${display}/`, candidate: `candidate:1 1 UDP 2122317823 ${host} ${port} typ host` };
3015
4174
  }
3016
4175
 
4176
+ // src/blueprint.ts
4177
+ function defineBlueprint(x) {
4178
+ return x;
4179
+ }
4180
+ var BlueprintError = class extends OneLobbyError {
4181
+ constructor(path, rule, detail) {
4182
+ super("bad_config", `onelobby.json: ${path ? path + ": " : ""}${detail}`);
4183
+ this.path = path;
4184
+ this.rule = rule;
4185
+ }
4186
+ path;
4187
+ rule;
4188
+ };
4189
+ var obj = (props, req) => ({ k: "obj", props, req });
4190
+ var bool = { k: "bool" };
4191
+ var str = (o = {}) => ({ k: "str", ...o });
4192
+ var int = (min, max, zeroOr) => ({ k: "int", min, max, zeroOr });
4193
+ var en = (...vals) => ({ k: "enum", vals });
4194
+ var ROOT = obj(
4195
+ {
4196
+ version: { k: "const", val: 1 },
4197
+ game: obj(
4198
+ { id: str({ pattern: /^[A-Za-z0-9._-]{1,64}$/ }), netVersion: str({ min: 1, max: 32 }), title: str({ max: 64 }) },
4199
+ ["id"]
4200
+ ),
4201
+ engine: en("web", "node-browser", "godot-csharp", "unity", "dotnet"),
4202
+ hosting: obj({
4203
+ direct: bool,
4204
+ server: bool,
4205
+ lobbyServer: str(),
4206
+ directPort: int(1024, 65535),
4207
+ openPort: bool,
4208
+ nostr: bool,
4209
+ nostrRelays: { k: "strarr" },
4210
+ relayOnly: bool,
4211
+ allowedOrigins: { k: "strarr" }
4212
+ }),
4213
+ lobby: obj({
4214
+ maxPlayers: int(2, 64),
4215
+ listed: bool,
4216
+ approveJoins: bool,
4217
+ password: bool,
4218
+ lateJoin: bool,
4219
+ rejoin: bool,
4220
+ joinLink: bool,
4221
+ meta: { k: "meta" }
4222
+ }),
4223
+ sync: obj({ mode: en("raw", "lockstep", "replication"), tickRate: int(1, 120), hashEveryTicks: int(1, 1e5) }),
4224
+ players: obj({ identity: en("name", "anonymous", "platform"), maxNameLength: int(1, 24) }),
4225
+ chat: obj({ text: bool, voice: bool, voiceMode: en("pushToTalk", "voiceActivity") }),
4226
+ session: obj({
4227
+ hostLeaves: en("end", "migrate"),
4228
+ pause: en("off", "host", "anyone"),
4229
+ readyTimeoutMs: int(0, 6e5),
4230
+ peerTimeoutMs: int(6e3, 6e5, true),
4231
+ rateLimit: { k: "rate" },
4232
+ runInBackground: bool
4233
+ }),
4234
+ platforms: obj({ steam: { k: "steam" }, epic: { k: "epic" }, discord: bool }),
4235
+ ui: obj({ kind: en("drop-in", "generated", "none"), entry: str(), theme: en("light", "dark", "auto") })
4236
+ },
4237
+ ["game"]
4238
+ );
4239
+ var IGNORED = /* @__PURE__ */ new Set(["$schema", "$comment"]);
4240
+ var Fail = class {
4241
+ constructor(path, rule, detail) {
4242
+ this.path = path;
4243
+ this.rule = rule;
4244
+ this.detail = detail;
4245
+ }
4246
+ path;
4247
+ rule;
4248
+ detail;
4249
+ };
4250
+ var join = (p, k) => p ? `${p}.${k}` : String(k);
4251
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4252
+ function check(n, v, path) {
4253
+ switch (n.k) {
4254
+ case "obj": {
4255
+ if (!isObj(v)) throw new Fail(path, "type", "must be an object");
4256
+ for (const r of n.req ?? []) if (!(r in v)) throw new Fail(join(path, r), "required", "is required");
4257
+ for (const k of Object.keys(v)) if (!IGNORED.has(k) && !(k in n.props)) throw new Fail(join(path, k), "unknown_key", "unknown key");
4258
+ for (const [k, sub] of Object.entries(n.props)) if (k in v) check(sub, v[k], join(path, k));
4259
+ return;
4260
+ }
4261
+ case "str": {
4262
+ if (typeof v !== "string") throw new Fail(path, "type", "must be a string");
4263
+ if (n.min !== void 0 && v.length < n.min || n.max !== void 0 && v.length > n.max)
4264
+ throw new Fail(path, "length", `must be ${n.min ?? 0} to ${n.max ?? "any"} characters`);
4265
+ if (n.pattern && !n.pattern.test(v)) throw new Fail(path, "pattern", `must match ${n.pattern.source}`);
4266
+ return;
4267
+ }
4268
+ case "int": {
4269
+ if (typeof v !== "number" || !Number.isInteger(v)) throw new Fail(path, "type", "must be an integer");
4270
+ if (n.zeroOr && v === 0) return;
4271
+ if (n.min !== void 0 && v < n.min || n.max !== void 0 && v > n.max)
4272
+ throw new Fail(path, "range", `must be ${n.zeroOr ? "0 or " : ""}${n.min ?? "any"} to ${n.max ?? "any"}`);
4273
+ return;
4274
+ }
4275
+ case "bool":
4276
+ if (typeof v !== "boolean") throw new Fail(path, "type", "must be true or false");
4277
+ return;
4278
+ case "enum":
4279
+ if (typeof v !== "string" || !n.vals.includes(v)) throw new Fail(path, "enum", `must be one of ${n.vals.join(", ")}`);
4280
+ return;
4281
+ case "const":
4282
+ if (v !== n.val) throw new Fail(path, "const", `must be ${n.val}`);
4283
+ return;
4284
+ case "strarr": {
4285
+ if (!Array.isArray(v)) throw new Fail(path, "type", "must be an array of strings");
4286
+ v.forEach((x, i) => {
4287
+ if (typeof x !== "string") throw new Fail(join(path, i), "type", "must be a string");
4288
+ });
4289
+ return;
4290
+ }
4291
+ case "meta": {
4292
+ if (!isObj(v)) throw new Fail(path, "type", "must be an object of strings");
4293
+ const keys = Object.keys(v);
4294
+ if (keys.length > 16) throw new Fail(path, "length", "at most 16 keys");
4295
+ for (const k of keys) {
4296
+ if (k.length > 64) throw new Fail(join(path, k), "length", "key is longer than 64 characters");
4297
+ const x = v[k];
4298
+ if (typeof x !== "string") throw new Fail(join(path, k), "type", "must be a string");
4299
+ if (x.length > 64) throw new Fail(join(path, k), "length", "value is longer than 64 characters");
4300
+ }
4301
+ return;
4302
+ }
4303
+ case "rate":
4304
+ check(obj({ messages: int(1), perMs: int(1) }, ["messages", "perMs"]), v, path);
4305
+ return;
4306
+ case "steam":
4307
+ if (typeof v === "boolean") return;
4308
+ if (!isObj(v)) throw new Fail(path, "type", "must be true, false or { appId }");
4309
+ check(obj({ appId: int(1) }), v, path);
4310
+ return;
4311
+ case "epic": {
4312
+ if (typeof v === "boolean") return;
4313
+ if (!isObj(v)) throw new Fail(path, "type", "must be true, false or { productId }");
4314
+ for (const k of Object.keys(v)) if (!IGNORED.has(k) && typeof v[k] !== "string") throw new Fail(join(path, k), "type", "must be a string");
4315
+ return;
4316
+ }
4317
+ }
4318
+ }
4319
+ var sect = (x) => isObj(x) ? x : {};
4320
+ function strip(o) {
4321
+ if (Array.isArray(o)) return o.map(strip);
4322
+ if (!isObj(o)) return o;
4323
+ const out = {};
4324
+ for (const [k, v] of Object.entries(o)) if (k !== "$comment" && k !== "$schema") out[k] = strip(v);
4325
+ return out;
4326
+ }
4327
+ function deepFreeze(o) {
4328
+ if (typeof o === "object" && o !== null && !Object.isFrozen(o)) {
4329
+ Object.freeze(o);
4330
+ for (const v of Object.values(o)) deepFreeze(v);
4331
+ }
4332
+ return o;
4333
+ }
4334
+ function fill(input) {
4335
+ const game = sect(input.game);
4336
+ const engine = input.engine;
4337
+ const hosting = sect(input.hosting);
4338
+ const lobby = sect(input.lobby);
4339
+ const sync = sect(input.sync);
4340
+ const players = sect(input.players);
4341
+ const chat = sect(input.chat);
4342
+ const session = sect(input.session);
4343
+ const platforms = sect(input.platforms);
4344
+ const ui = sect(input.ui);
4345
+ const tickRate = sync.tickRate ?? 20;
4346
+ const web = engine === "web";
4347
+ const out = {
4348
+ version: 1,
4349
+ game: { id: game.id, netVersion: game.netVersion ?? "1", ...game.title !== void 0 ? { title: game.title } : {} },
4350
+ ...engine !== void 0 ? { engine } : {},
4351
+ hosting: {
4352
+ direct: hosting.direct ?? !web,
4353
+ server: hosting.server ?? web,
4354
+ lobbyServer: hosting.lobbyServer ?? "",
4355
+ directPort: hosting.directPort ?? 47810,
4356
+ openPort: hosting.openPort ?? true,
4357
+ nostr: hosting.nostr ?? true,
4358
+ nostrRelays: hosting.nostrRelays ?? [...DEFAULT_NOSTR_RELAYS],
4359
+ relayOnly: hosting.relayOnly ?? false,
4360
+ allowedOrigins: hosting.allowedOrigins ?? []
4361
+ },
4362
+ lobby: {
4363
+ maxPlayers: lobby.maxPlayers ?? 4,
4364
+ listed: lobby.listed ?? false,
4365
+ approveJoins: lobby.approveJoins ?? false,
4366
+ password: lobby.password ?? false,
4367
+ lateJoin: lobby.lateJoin ?? true,
4368
+ rejoin: lobby.rejoin ?? true,
4369
+ joinLink: lobby.joinLink ?? (web || engine === "node-browser"),
4370
+ meta: lobby.meta ?? {}
4371
+ },
4372
+ sync: { mode: sync.mode ?? "raw", tickRate, hashEveryTicks: sync.hashEveryTicks ?? tickRate },
4373
+ players: { identity: players.identity ?? "name", maxNameLength: players.maxNameLength ?? 24 },
4374
+ chat: { text: chat.text ?? true, voice: chat.voice ?? false, voiceMode: chat.voiceMode ?? "pushToTalk" },
4375
+ session: {
4376
+ hostLeaves: session.hostLeaves ?? "end",
4377
+ pause: session.pause ?? "off",
4378
+ readyTimeoutMs: session.readyTimeoutMs ?? 15e3,
4379
+ peerTimeoutMs: session.peerTimeoutMs ?? 0,
4380
+ ...session.rateLimit !== void 0 ? { rateLimit: session.rateLimit } : {},
4381
+ runInBackground: session.runInBackground ?? true
4382
+ },
4383
+ platforms: { steam: platforms.steam ?? false, epic: platforms.epic ?? false, discord: platforms.discord ?? false },
4384
+ ui: {
4385
+ kind: ui.kind ?? (web ? "drop-in" : "generated"),
4386
+ ...ui.entry !== void 0 ? { entry: ui.entry } : {},
4387
+ theme: ui.theme ?? "auto"
4388
+ }
4389
+ };
4390
+ return out;
4391
+ }
4392
+ function crossRules(b) {
4393
+ if (!b.hosting.direct && !b.hosting.server) throw new Fail("hosting", "hosting_none", "at least one of direct, server must be true");
4394
+ if (b.lobby.listed && b.lobby.approveJoins) throw new Fail("lobby", "listed_and_approve", "listed and approveJoins cannot both be true");
4395
+ if (b.engine === "web" && b.hosting.direct) throw new Fail("hosting.direct", "direct_needs_listener", "engine web cannot host direct (a browser cannot listen on a port)");
4396
+ const csharp = b.engine === "godot-csharp" || b.engine === "unity" || b.engine === "dotnet";
4397
+ if (b.sync.mode === "replication" && !csharp) throw new Fail("sync.mode", "replication_needs_csharp", "replication needs engine godot-csharp, unity or dotnet");
4398
+ if (b.sync.mode === "replication" && b.session.hostLeaves === "migrate") throw new Fail("session.hostLeaves", "migrate_with_replication", "replication has no host migration; use end");
4399
+ if (b.session.hostLeaves === "migrate" && !b.hosting.server) throw new Fail("session.hostLeaves", "migrate_needs_server", "migrate needs hosting.server (direct lobbies always end)");
4400
+ }
4401
+ function parseBlueprint(json) {
4402
+ let b;
4403
+ try {
4404
+ check(ROOT, json, "");
4405
+ b = fill(strip(json));
4406
+ crossRules(b);
4407
+ } catch (e) {
4408
+ if (e instanceof Fail) throw new BlueprintError(e.path, e.rule, e.detail);
4409
+ throw e;
4410
+ }
4411
+ return deepFreeze(b);
4412
+ }
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
+
3017
5743
  // src/onelobby.ts
3018
5744
  var LIB_NAME = "onelobby";
3019
- var LIB_VERSION = "0.4.1";
5745
+ var LIB_VERSION = "0.6.0";
3020
5746
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
3021
5747
  function hasMic() {
3022
5748
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -3034,7 +5760,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3034
5760
  for (const l of liveSeats) if (l.resumeToken === token) return true;
3035
5761
  return false;
3036
5762
  }
3037
- var OneLobby = class extends Emitter {
5763
+ var OneLobby = class _OneLobby extends Emitter {
3038
5764
  options;
3039
5765
  socketFactory;
3040
5766
  linkFactory;
@@ -3044,6 +5770,54 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3044
5770
  createMic;
3045
5771
  createSink;
3046
5772
  storage;
5773
+ nostrConnectFn;
5774
+ /** The validated, frozen onelobby.json this kit was built from (`OneLobby.fromConfig`); undefined for `new OneLobby`. */
5775
+ blueprint;
5776
+ /**
5777
+ * Build a kit from an onelobby.json (already parsed; docs/design/blueprint.md s.7). Throws `bad_config` for an
5778
+ * invalid file or name, `identity_anonymous` for a name when `players.identity` is "anonymous". Then `host()` and
5779
+ * `hostDirect()` default their options from the file and the file's chat/listing switches are enforced.
5780
+ */
5781
+ static fromConfig(json, overrides = {}) {
5782
+ const bp = parseBlueprint(json);
5783
+ const name = (overrides.displayName ?? "").trim();
5784
+ if (name) {
5785
+ if (bp.players.identity === "anonymous") throw new OneLobbyError("identity_anonymous", "this game uses anonymous players: no display names");
5786
+ if (name.length > bp.players.maxNameLength)
5787
+ throw new BlueprintError("displayName", "length", `must be at most ${bp.players.maxNameLength} characters`);
5788
+ }
5789
+ const kit = new _OneLobby({
5790
+ gameId: bp.game.id,
5791
+ netVersion: bp.game.netVersion,
5792
+ lobbyServer: overrides.lobbyServer ?? bp.hosting.lobbyServer,
5793
+ mode: bp.sync.mode === "lockstep" ? "lockstep" : "raw",
5794
+ // replication is C# only; JS speaks raw
5795
+ tickRate: bp.sync.tickRate,
5796
+ hashEveryTicks: bp.sync.hashEveryTicks,
5797
+ displayName: name,
5798
+ relayOnly: bp.hosting.relayOnly,
5799
+ readyTimeoutMs: bp.session.readyTimeoutMs,
5800
+ peerTimeoutMs: bp.session.peerTimeoutMs,
5801
+ rememberSeat: overrides.rememberSeat ?? bp.lobby.rejoin,
5802
+ advanced: overrides.advanced
5803
+ });
5804
+ kit.blueprint = bp;
5805
+ return kit;
5806
+ }
5807
+ /** @internal Host options the blueprint implies (empty without one). */
5808
+ blueprintHostDefaults() {
5809
+ const bp = this.blueprint;
5810
+ if (!bp) return {};
5811
+ const name = this.options.displayName;
5812
+ return {
5813
+ name: name ? `${name}'s game` : "Game",
5814
+ maxPlayers: bp.lobby.maxPlayers,
5815
+ visibility: bp.lobby.listed ? "public" : bp.lobby.approveJoins ? "private" : "unlisted",
5816
+ lateJoin: bp.lobby.lateJoin,
5817
+ meta: { ...bp.lobby.meta },
5818
+ ...bp.session.rateLimit ? { rateLimit: { ...bp.session.rateLimit } } : {}
5819
+ };
5820
+ }
3047
5821
  constructor(o) {
3048
5822
  super();
3049
5823
  const tickRate = o.tickRate ?? 20;
@@ -3071,6 +5845,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3071
5845
  this.createMic = mic !== void 0 ? mic : hasMic() ? () => new WorkletMic() : null;
3072
5846
  const sink = pick("createSink");
3073
5847
  this.createSink = sink !== void 0 ? sink : typeof AudioContext !== "undefined" ? () => new WebAudioSink() : null;
5848
+ this.nostrConnectFn = a.nostrConnect;
3074
5849
  this.storage = o.rememberSeat === false ? null : a.storage !== void 0 ? a.storage : browserStorage();
3075
5850
  }
3076
5851
  // ---- remembered seat -------------------------------------------------------------------------------------------
@@ -3152,6 +5927,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3152
5927
  }
3153
5928
  /** The signaling socket only (no hello yet); with a timeout (direct IP), no answer is `direct_unreachable`. */
3154
5929
  async open(url, factory, timeoutMs) {
5930
+ if (!url.trim() && factory === defaultSocketFactory) throw new OneLobbyError("no_server", "no lobby server address (set hosting.lobbyServer or pass lobbyServer)");
3155
5931
  const sig = new SignalingClient(url, factory, this.clock);
3156
5932
  if (timeoutMs > 0) {
3157
5933
  let cancel = () => {
@@ -3189,6 +5965,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3189
5965
  rateLimit
3190
5966
  });
3191
5967
  lobby.errorSink = (e) => this.emit("error", e);
5968
+ if (this.blueprint) guardLobby(lobby, this.blueprint);
3192
5969
  if (this.storage && !direct) {
3193
5970
  liveSeats.add(lobby);
3194
5971
  this.saveSeat(lobby.code, lobby.resumeToken);
@@ -3200,7 +5977,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3200
5977
  }
3201
5978
  return lobby;
3202
5979
  }
3203
- async host(h) {
5980
+ /** With a blueprint (`fromConfig`) every option defaults from `lobby.*`; explicit options win. */
5981
+ async host(opts) {
5982
+ const h = { name: "Game", maxPlayers: 4, ...this.blueprintHostDefaults(), ...definedOnly(opts) };
3204
5983
  const { sig, welcome } = await this.connect();
3205
5984
  try {
3206
5985
  const state = await sig.request(
@@ -3245,18 +6024,18 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3245
6024
  }
3246
6025
  }
3247
6026
  /**
3248
- * 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
3249
6028
  * or hyphens) or, advanced, an address (`ip`, `ip:port`, `[v6]:port`). No lobby server is involved: the code is
3250
6029
  * the host's address. A mistyped code fails at once with `bad_code`; nobody listening is `direct_unreachable`.
3251
6030
  * The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
3252
6031
  */
3253
6032
  async joinDirect(codeOrAddress, opts = {}) {
3254
- const { addr, links } = this.directTarget(codeOrAddress, opts);
3255
- const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
6033
+ const t = this.directTarget(codeOrAddress, opts);
6034
+ const { sig, welcome } = await this.connectDirect(t, opts);
3256
6035
  try {
3257
6036
  const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3258
- const lobby = this.makeLobby(sig, welcome, state, { links });
3259
- lobby.joinCode = joinedWith(addr);
6037
+ const lobby = this.makeLobby(sig, this.withStun(welcome, t, opts), state, { links: t.links });
6038
+ lobby.joinCode = t.joinedWith;
3260
6039
  return lobby;
3261
6040
  } catch (e) {
3262
6041
  sig.close();
@@ -3269,24 +6048,55 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3269
6048
  * same peer id comes back. Rejects with `invalid_token` when the seat is gone: offer `joinDirect` then.
3270
6049
  */
3271
6050
  async rejoinDirect(codeOrAddress, resumeToken, opts = {}) {
3272
- const { addr, links } = this.directTarget(codeOrAddress, opts);
3273
- const lobby = await this.resumeAt(resumeToken, addr.url, opts.connectTimeoutMs ?? 5e3, { links });
3274
- lobby.joinCode = joinedWith(addr);
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;
3275
6054
  return lobby;
3276
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
+ }
3277
6079
  directTarget(codeOrAddress, opts) {
3278
6080
  let input = codeOrAddress.trim();
3279
6081
  if (looksLikeJoinCode(input)) {
3280
6082
  const d = decodeJoinCode(input);
3281
6083
  if ("error" in d)
3282
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
+ }
3283
6089
  input = `${d.ip}:${d.port}`;
3284
6090
  }
3285
6091
  const addr = parseDirectAddress(input, opts.defaultPort ?? DEFAULT_DIRECT_PORT);
3286
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)`);
3287
6093
  const inner = this.linkFactory;
3288
6094
  const links = { create: (o) => inner.create({ ...o, iceServers: [], extraRemoteCandidates: [addr.candidate] }) };
3289
- 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 };
3290
6100
  }
3291
6101
  /** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
3292
6102
  async hostDirectWith(h, hooks) {
@@ -3311,17 +6121,27 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3311
6121
  throw e;
3312
6122
  }
3313
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
+ }
3314
6134
  async rejoin(resumeToken) {
3315
6135
  return this.resumeAt(resumeToken, this.options.lobbyServer, 0);
3316
6136
  }
3317
- async resumeAt(resumeToken, url, timeoutMs, direct) {
3318
- 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);
3319
6139
  try {
3320
6140
  const lobbyP = sig.expect("lobby");
3321
6141
  const welcomeP = sig.request(this.helloMsg(resumeToken), "welcome");
3322
6142
  lobbyP.catch(() => {
3323
6143
  });
3324
- const welcome = await welcomeP;
6144
+ const welcome = tweakWelcome ? tweakWelcome(await welcomeP) : await welcomeP;
3325
6145
  if (welcome.peerId === void 0) throw new OneLobbyError("invalid_token", "resume token unknown or expired");
3326
6146
  const state = await lobbyP;
3327
6147
  return this.makeLobby(sig, welcome, state, direct);
@@ -3331,6 +6151,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3331
6151
  }
3332
6152
  }
3333
6153
  async list(opts = {}) {
6154
+ if (this.blueprint && !this.blueprint.hosting.server) throw new OneLobbyError("listing_disabled", "this game has no lobby server (hosting.server is false)");
3334
6155
  const { sig } = await this.connect();
3335
6156
  try {
3336
6157
  const res = await sig.request(
@@ -3351,6 +6172,36 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3351
6172
  return addr.display;
3352
6173
  }
3353
6174
  }
6175
+ function definedOnly(o) {
6176
+ const out = {};
6177
+ if (o) {
6178
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
6179
+ }
6180
+ return out;
6181
+ }
6182
+ function guardLobby(lobby, bp) {
6183
+ lobby.voice.mode = bp.chat.voiceMode;
6184
+ if (!bp.chat.voice) {
6185
+ const voice = lobby.voice;
6186
+ const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(voice), "enabled");
6187
+ if (d?.get && d.set) {
6188
+ Object.defineProperty(voice, "enabled", {
6189
+ configurable: true,
6190
+ get: () => d.get.call(voice),
6191
+ set: (v) => {
6192
+ if (v) throw new OneLobbyError("voice_disabled", "this game has voice chat turned off (chat.voice is false)");
6193
+ d.set.call(voice, v);
6194
+ }
6195
+ });
6196
+ }
6197
+ }
6198
+ lobby.pause.mode = bp.session.pause;
6199
+ if (!bp.chat.text) {
6200
+ lobby.chat.send = () => {
6201
+ throw new OneLobbyError("chat_disabled", "this game has text chat turned off (chat.text is false)");
6202
+ };
6203
+ }
6204
+ }
3354
6205
 
3355
6206
  // src/friendly.ts
3356
6207
  var ErrorCodes = {
@@ -3378,7 +6229,18 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3378
6229
  Closed: "closed",
3379
6230
  ServerLost: "server_lost",
3380
6231
  InvalidToken: "invalid_token",
3381
- NoServer: "no_server"
6232
+ NoServer: "no_server",
6233
+ // from the shared pause (lobby.pause)
6234
+ NotInGame: "not_in_game",
6235
+ NotAllowed: "not_allowed",
6236
+ // from a blueprint (onelobby.json)
6237
+ BadConfig: "bad_config",
6238
+ VoiceDisabled: "voice_disabled",
6239
+ ChatDisabled: "chat_disabled",
6240
+ PauseDisabled: "pause_disabled",
6241
+ ListingDisabled: "listing_disabled",
6242
+ NostrDisabled: "nostr_disabled",
6243
+ IdentityAnonymous: "identity_anonymous"
3382
6244
  };
3383
6245
  var TEXT = {
3384
6246
  // server errors (spec/signaling.md)
@@ -3401,7 +6263,18 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3401
6263
  server_lost: { message: "Lost the connection to the game server.", hint: "Your game may keep going; new players can't join until it's back." },
3402
6264
  invalid_token: { message: "Your old seat in that lobby is no longer available.", hint: "Join again with the lobby code." },
3403
6265
  ice_failed: { message: "Couldn't connect to the other players.", hint: "Some networks (school, work, public Wi-Fi) block game connections. Try another network or a phone hotspot." },
6266
+ no_server: { message: "This game has no lobby server address yet.", hint: "Enter one in the multiplayer settings, or host from your own computer with a join code." },
3404
6267
  timeout: { message: "The connection took too long.", hint: "Check your internet connection, then try again." },
6268
+ voice_disabled: { message: "Voice chat is turned off in this game." },
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." },
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." },
6276
+ identity_anonymous: { message: "This game doesn't use player names.", hint: "Everyone shows up as Player 1, Player 2, and so on." },
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." },
3405
6278
  left: { message: "You left the lobby." },
3406
6279
  unknown: { message: "Something went wrong.", hint: "Try again. If it keeps happening, reload the page." }
3407
6280
  };
@@ -3457,6 +6330,16 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3457
6330
  u.searchParams.set(param, formatCode(norm).replace(" ", "-"));
3458
6331
  return u.toString();
3459
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
+ }
3460
6343
  function codeFromUrl(url, param = "join") {
3461
6344
  const href = url ?? currentHref();
3462
6345
  if (!href) return null;
@@ -3475,9 +6358,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3475
6358
  var KeepaliveError = OneLobbyError;
3476
6359
 
3477
6360
  // src/ui/controller.ts
3478
- var enc3 = new TextEncoder();
6361
+ var enc4 = new TextEncoder();
3479
6362
  var dec3 = new TextDecoder();
3480
- var toBytes = (w) => enc3.encode(JSON.stringify(w));
6363
+ var toBytes = (w) => enc4.encode(JSON.stringify(w));
3481
6364
  var SharedState = class {
3482
6365
  constructor(c) {
3483
6366
  this.c = c;