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