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