onelobby 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/blueprint.schema.json +8 -1
- package/dist/blueprint.d.ts +4 -0
- package/dist/blueprint.js +5 -0
- package/dist/blueprint.js.map +1 -1
- package/dist/codec.d.ts +23 -0
- package/dist/codec.js +28 -0
- package/dist/codec.js.map +1 -1
- package/dist/direct/browser-host.d.ts +46 -0
- package/dist/direct/browser-host.js +93 -0
- package/dist/direct/browser-host.js.map +1 -0
- package/dist/direct/hub-host.d.ts +34 -23
- package/dist/direct/hub-host.js +75 -59
- package/dist/direct/hub-host.js.map +1 -1
- package/dist/direct/hub-local.d.ts +22 -0
- package/dist/direct/hub-local.js +46 -0
- package/dist/direct/hub-local.js.map +1 -0
- package/dist/direct/hub.d.ts +39 -10
- package/dist/direct/hub.js +134 -24
- package/dist/direct/hub.js.map +1 -1
- package/dist/direct/index.d.ts +36 -3
- package/dist/direct/index.js +146 -20
- package/dist/direct/index.js.map +1 -1
- package/dist/direct/metrics.d.ts +12 -0
- package/dist/direct/metrics.js +45 -0
- package/dist/direct/metrics.js.map +1 -0
- package/dist/direct/portmap.d.ts +9 -2
- package/dist/direct/portmap.js +7 -2
- package/dist/direct/portmap.js.map +1 -1
- package/dist/direct/sha1.d.ts +16 -0
- package/dist/direct/sha1.js +111 -0
- package/dist/direct/sha1.js.map +1 -0
- package/dist/direct-address.d.ts +2 -2
- package/dist/direct-address.js.map +1 -1
- package/dist/friendly.d.ts +24 -0
- package/dist/friendly.js +30 -0
- package/dist/friendly.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/internals.d.ts +9 -0
- package/dist/internals.js +9 -0
- package/dist/internals.js.map +1 -1
- package/dist/join-code-kind.d.ts +10 -0
- package/dist/join-code-kind.js +10 -0
- package/dist/join-code-kind.js.map +1 -0
- package/dist/joincode.d.ts +10 -1
- package/dist/joincode.js +23 -3
- package/dist/joincode.js.map +1 -1
- package/dist/lobby.d.ts +94 -5
- package/dist/lobby.js +177 -18
- package/dist/lobby.js.map +1 -1
- package/dist/lockstep.d.ts +8 -0
- package/dist/lockstep.js +23 -2
- package/dist/lockstep.js.map +1 -1
- package/dist/nostr/crypto.d.ts +20 -0
- package/dist/nostr/crypto.js +78 -0
- package/dist/nostr/crypto.js.map +1 -0
- package/dist/nostr/host.d.ts +66 -0
- package/dist/nostr/host.js +196 -0
- package/dist/nostr/host.js.map +1 -0
- package/dist/nostr/limits.d.ts +33 -0
- package/dist/nostr/limits.js +38 -0
- package/dist/nostr/limits.js.map +1 -0
- package/dist/nostr/link.d.ts +70 -0
- package/dist/nostr/link.js +180 -0
- package/dist/nostr/link.js.map +1 -0
- package/dist/nostr/relays.d.ts +79 -0
- package/dist/nostr/relays.js +325 -0
- package/dist/nostr/relays.js.map +1 -0
- package/dist/nostr/room.d.ts +43 -0
- package/dist/nostr/room.js +104 -0
- package/dist/nostr/room.js.map +1 -0
- package/dist/nostr/secp256k1.d.ts +10 -0
- package/dist/nostr/secp256k1.js +171 -0
- package/dist/nostr/secp256k1.js.map +1 -0
- package/dist/nostr/socket.d.ts +48 -0
- package/dist/nostr/socket.js +141 -0
- package/dist/nostr/socket.js.map +1 -0
- package/dist/onelobby.bundle.js +146 -0
- package/dist/onelobby.bundle.js.map +7 -0
- package/dist/onelobby.bundle.mjs +11 -5
- package/dist/onelobby.bundle.mjs.map +4 -4
- package/dist/onelobby.d.ts +31 -3
- package/dist/onelobby.global.js +2673 -60
- package/dist/onelobby.global.js.map +4 -4
- package/dist/onelobby.global.min.js +11 -5
- package/dist/onelobby.global.min.js.map +4 -4
- package/dist/onelobby.js +77 -14
- package/dist/onelobby.js.map +1 -1
- package/dist/pause.d.ts +85 -0
- package/dist/pause.js +160 -0
- package/dist/pause.js.map +1 -0
- package/dist/signaling.d.ts +2 -0
- package/dist/signaling.js.map +1 -1
- package/dist/socket.d.ts +15 -6
- package/dist/socket.js.map +1 -1
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +18 -2
- package/dist/testing.js.map +1 -1
- package/dist/transport.d.ts +5 -1
- package/dist/transport.js.map +1 -1
- package/dist/webrtc.d.ts +6 -0
- package/dist/webrtc.js +27 -3
- package/dist/webrtc.js.map +1 -1
- package/package.json +1 -1
- package/dist/deprecated.d.ts +0 -18
- package/dist/deprecated.js +0 -11
- package/dist/deprecated.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hub-local.js","sourceRoot":"","sources":["../../src/direct/hub-local.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,MAAM,OAAO,cAAc;IAUI;IAT7B,wFAAwF;IACxF,MAAM,CAAU,OAAO,GAAG,OAAO,CAAC;IAClC,UAAU,GAAG,CAAC,CAAC;IACf,MAAM,GAAyB,IAAI,CAAC;IACpC,SAAS,GAA4B,IAAI,CAAC;IAC1C,OAAO,GAA0B,IAAI,CAAC;IACtC,OAAO,GAA0B,IAAI,CAAC;IAC9B,OAAO,CAAiB;IAEhC,YAA6B,GAAiB;QAAjB,QAAG,GAAH,GAAG,CAAc;QAC5C,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,KAAK,CAAC,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,CACjJ,IAAI,CAAC,MAAM,EAAE,CACd,CAAC;QACF,cAAc,CAAC,GAAG,EAAE;YAClB,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClB,IAAI,CAAC,MAAM,EAAE,CAAC;gBACd,OAAO;YACT,CAAC;YACD,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;YACpB,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;QAClB,CAAC,CAAC,CAAC;IACL,CAAC;IAED,IAAI,CAAC,IAAY;QACf,IAAI,IAAI,CAAC,UAAU,KAAK,CAAC,IAAI,IAAI,CAAC,OAAO;YAAE,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAClF,CAAC;IAED,KAAK;QACH,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC;QACvB,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,KAAK,CAAC,CAAC;QACtC,IAAI,CAAC,MAAM,EAAE,CAAC;QACd,IAAI,OAAO,IAAI,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IAC3C,CAAC;IAEO,MAAM;QACZ,IAAI,IAAI,CAAC,UAAU,KAAK,CAAC;YAAE,OAAO;QAClC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;QACpB,cAAc,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IACzC,CAAC","sourcesContent":["import type { SocketLike } from \"../transport.js\";\nimport type { SignalingHub, Session } from \"./hub.js\";\n\n/**\n * A direct host's own signaling socket (C#'s `HubSignalingSocket`): sends go straight into the hub; the hub's\n * replies are delivered on a microtask, so callbacks never run inside the hub. No Node APIs, so a browser tab can\n * use it too (`kit.hostNostr`).\n */\nexport class LocalHubSocket implements SocketLike {\n /** Address the hub sees for the host itself (rate limits and kicks are per address). */\n static readonly ADDRESS = \"local\";\n readyState = 0;\n onopen: SocketLike[\"onopen\"] = null;\n onmessage: SocketLike[\"onmessage\"] = null;\n onclose: SocketLike[\"onclose\"] = null;\n onerror: SocketLike[\"onerror\"] = null;\n private session: Session | null;\n\n constructor(private readonly hub: SignalingHub) {\n this.session = hub.connect(LocalHubSocket.ADDRESS, (text) => queueMicrotask(() => this.readyState === 1 && this.onmessage?.({ data: text })), () =>\n this.finish(),\n );\n queueMicrotask(() => {\n if (!this.session) {\n this.finish();\n return;\n }\n this.readyState = 1;\n this.onopen?.();\n });\n }\n\n send(data: string): void {\n if (this.readyState === 1 && this.session) this.hub.receive(this.session, data);\n }\n\n close(): void {\n const s = this.session;\n const wasOpen = this.readyState === 1;\n this.finish();\n if (wasOpen && s) this.hub.disconnect(s);\n }\n\n private finish(): void {\n if (this.readyState === 3) return;\n this.readyState = 3;\n queueMicrotask(() => this.onclose?.());\n }\n}\n"]}
|
package/dist/direct/hub.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The signaling hub (protocol lk1) in TypeScript, for a Node process that hosts direct
|
|
3
|
-
* (docs/direct-ip.md s.2): a line-by-line port of `server/OneLobby.Hub/Hub.cs`, held to the same rules by
|
|
2
|
+
* The signaling hub (protocol lk1) in TypeScript, for a Node process or a browser tab that hosts direct lobbies
|
|
3
|
+
* (docs/design/direct-ip.md s.2): a line-by-line port of `server/OneLobby.Hub/Hub.cs`, held to the same rules by
|
|
4
4
|
* `conformance/server`. Transport-agnostic: feed it text frames with `receive`, call `tick` about once a second.
|
|
5
|
-
* Metrics
|
|
6
|
-
*
|
|
5
|
+
* Metrics (same names, labels and help text as C#, rendered by `metrics.render()`) are counted too, so every hub shape
|
|
6
|
+
* looks the same from outside; everything else (codes, list, resume, private lobbies, kicks, migration claims, TURN
|
|
7
|
+
* credentials, rate limits) behaves as in C#.
|
|
7
8
|
*/
|
|
9
|
+
import { Metrics } from "./metrics.js";
|
|
10
|
+
export { Metrics };
|
|
8
11
|
export declare const PROTO = "lk1";
|
|
9
12
|
/** The settings the hub reads; same names and defaults as C#'s `HubOptions`. */
|
|
10
13
|
export interface HubOptions {
|
|
@@ -26,6 +29,8 @@ export interface HubOptions {
|
|
|
26
29
|
createPerMin: number;
|
|
27
30
|
joinPerMin: number;
|
|
28
31
|
signalPerSec: number;
|
|
32
|
+
/** Open sockets per address (loopback included); `disableIpLimits` lifts it, `rateScale` scales it. */
|
|
33
|
+
socketsPerIp: number;
|
|
29
34
|
openLobbiesPerIp: number;
|
|
30
35
|
idleTimeoutMs: number;
|
|
31
36
|
resumeWindowMs: number;
|
|
@@ -39,8 +44,14 @@ export interface HubOptions {
|
|
|
39
44
|
issueCodes: boolean;
|
|
40
45
|
}
|
|
41
46
|
export declare function defaultHubOptions(): HubOptions;
|
|
42
|
-
/** Settings for a hub inside the host's game (docs/direct-ip.md s.2): no codes, no list, no ICE servers. */
|
|
47
|
+
/** Settings for a hub inside the host's game (docs/design/direct-ip.md s.2): no codes, no list, no ICE servers. */
|
|
43
48
|
export declare function directHubOptions(maxPlayers: number, password?: string | null): HubOptions;
|
|
49
|
+
/** A session's total `signal` budget is this many targets' worth of `signalPerSec` (spec/signaling.md rate limits). */
|
|
50
|
+
export declare const SIGNAL_FANOUT = 8;
|
|
51
|
+
/** Hub sessions bridged from Nostr have the address `nostr:<pubkey prefix>`. */
|
|
52
|
+
export declare const NOSTR_ADDR = "nostr:";
|
|
53
|
+
/** `signal` per second per (sender, Nostr target): relays reject high publish rates. */
|
|
54
|
+
export declare const NOSTR_SIGNAL_PER_SEC = 10;
|
|
44
55
|
/** Sliding-window counter: at most `limit` events per `windowMs`. */
|
|
45
56
|
export declare class RateWindow {
|
|
46
57
|
readonly limit: number;
|
|
@@ -60,8 +71,7 @@ export declare class Session {
|
|
|
60
71
|
readonly ip: string;
|
|
61
72
|
readonly connId: string;
|
|
62
73
|
readonly send: (text: string) => void;
|
|
63
|
-
readonly close: () => void;
|
|
64
|
-
readonly signalRate: RateWindow;
|
|
74
|
+
readonly close: (code?: number, reason?: string) => void;
|
|
65
75
|
closed: boolean;
|
|
66
76
|
lastSeen: number;
|
|
67
77
|
helloDone: boolean;
|
|
@@ -72,7 +82,13 @@ export declare class Session {
|
|
|
72
82
|
member: Member | null;
|
|
73
83
|
pendingIn: HubLobby | null;
|
|
74
84
|
pendingMember: Member | null;
|
|
75
|
-
constructor(id: number, ip: string, connId: string, send: (text: string) => void, close: () => void
|
|
85
|
+
constructor(id: number, ip: string, connId: string, send: (text: string) => void, close: (code?: number, reason?: string) => void);
|
|
86
|
+
/** Nostr joiners: the remote address of the host's WebRTC link to this session, once known (kick bans it too). */
|
|
87
|
+
linkIp: string | null;
|
|
88
|
+
/** One window per target member (a member belongs to one lobby, so a new lobby starts fresh). */
|
|
89
|
+
readonly signalRates: Map<Member, RateWindow>;
|
|
90
|
+
/** All signals of this session, so the per-target budget does not grow without bound with the lobby. */
|
|
91
|
+
signalTotal: RateWindow | null;
|
|
76
92
|
}
|
|
77
93
|
declare class Member {
|
|
78
94
|
peerId: number;
|
|
@@ -82,6 +98,7 @@ declare class Member {
|
|
|
82
98
|
detachedAt: number;
|
|
83
99
|
token: string;
|
|
84
100
|
ip: string;
|
|
101
|
+
renames: RateWindow | null;
|
|
85
102
|
}
|
|
86
103
|
declare class HubLobby {
|
|
87
104
|
code: string;
|
|
@@ -132,13 +149,20 @@ export declare class SignalingHub {
|
|
|
132
149
|
private createdOnce;
|
|
133
150
|
private members;
|
|
134
151
|
private readonly logSalt;
|
|
152
|
+
readonly metrics: Metrics;
|
|
135
153
|
constructor(options?: Partial<HubOptions>, deps?: HubDeps);
|
|
136
154
|
get lobbyCount(): number;
|
|
137
155
|
get socketCount(): number;
|
|
138
156
|
private get scale();
|
|
139
157
|
/** Registers a socket. Returns null (after sending server_full and closing) when over maxPeers. */
|
|
140
|
-
connect(ip: string, send: (text: string) => void, close: () => void): Session | null;
|
|
158
|
+
connect(ip: string, send: (text: string) => void, close: (code?: number, reason?: string) => void): Session | null;
|
|
141
159
|
disconnect(s: Session): void;
|
|
160
|
+
/**
|
|
161
|
+
* The host's WebRTC link to joiner `peerId` has the remote address `ip` (selected candidate pair; the host's own
|
|
162
|
+
* lobby reports it, a relayed or mDNS address must not be passed). Only Nostr sessions take it: their socket address
|
|
163
|
+
* is a pubkey the joiner chose. A joiner whose link address is banned is removed as kicked; a later kick bans it.
|
|
164
|
+
*/
|
|
165
|
+
reportLinkAddress(peerId: number, ip: string): void;
|
|
142
166
|
private closeSession;
|
|
143
167
|
/** Call about once a second: idle timeouts, resume-window expiry, code retirement. */
|
|
144
168
|
tick(): void;
|
|
@@ -153,8 +177,14 @@ export declare class SignalingHub {
|
|
|
153
177
|
private list;
|
|
154
178
|
private leave;
|
|
155
179
|
private signal;
|
|
180
|
+
/**
|
|
181
|
+
* spec/signaling.md rate limits: `signal` is counted per sender and target, so a host answering several joiners at once
|
|
182
|
+
* gets the budget for each, and per sender in total (SIGNAL_FANOUT targets' worth).
|
|
183
|
+
*/
|
|
184
|
+
private signalRate;
|
|
156
185
|
private requireHost;
|
|
157
186
|
private hostMeta;
|
|
187
|
+
private setName;
|
|
158
188
|
private hostPhase;
|
|
159
189
|
private kick;
|
|
160
190
|
private approve;
|
|
@@ -178,4 +208,3 @@ export declare class SignalingHub {
|
|
|
178
208
|
private logEvent;
|
|
179
209
|
private hashIp;
|
|
180
210
|
}
|
|
181
|
-
export {};
|
package/dist/direct/hub.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
/// <reference types="node" />
|
|
2
|
-
import { createHash, createHmac, randomBytes, randomInt } from "node:crypto";
|
|
3
1
|
/**
|
|
4
|
-
* The signaling hub (protocol lk1) in TypeScript, for a Node process that hosts direct
|
|
5
|
-
* (docs/direct-ip.md s.2): a line-by-line port of `server/OneLobby.Hub/Hub.cs`, held to the same rules by
|
|
2
|
+
* The signaling hub (protocol lk1) in TypeScript, for a Node process or a browser tab that hosts direct lobbies
|
|
3
|
+
* (docs/design/direct-ip.md s.2): a line-by-line port of `server/OneLobby.Hub/Hub.cs`, held to the same rules by
|
|
6
4
|
* `conformance/server`. Transport-agnostic: feed it text frames with `receive`, call `tick` about once a second.
|
|
7
|
-
* Metrics
|
|
8
|
-
*
|
|
5
|
+
* Metrics (same names, labels and help text as C#, rendered by `metrics.render()`) are counted too, so every hub shape
|
|
6
|
+
* looks the same from outside; everything else (codes, list, resume, private lobbies, kicks, migration claims, TURN
|
|
7
|
+
* credentials, rate limits) behaves as in C#.
|
|
9
8
|
*/
|
|
9
|
+
import { Metrics } from "./metrics.js";
|
|
10
|
+
import { randomBelow, randomHex, shortHash, turnCredential } from "./sha1.js";
|
|
11
|
+
export { Metrics };
|
|
10
12
|
export const PROTO = "lk1";
|
|
11
13
|
const NAME_MAX = 24;
|
|
12
14
|
const LOBBY_NAME_MAX = 48;
|
|
@@ -19,11 +21,11 @@ export function defaultHubOptions() {
|
|
|
19
21
|
return {
|
|
20
22
|
turnUrls: [], turnSecret: "", stunUrls: ["stun:stun.l.google.com:19302"], region: "", allowedGames: [], gameQuotas: {},
|
|
21
23
|
maxLobbies: 1000, maxPeers: 10000, hardMaxPlayers: 64, rateScale: 1, turnTtlSeconds: 3600, maxMessageBytes: 16 * 1024,
|
|
22
|
-
disableIpLimits: false, createPerMin: 10, joinPerMin: 60, signalPerSec: 20, openLobbiesPerIp: 8, idleTimeoutMs: 30_000,
|
|
24
|
+
disableIpLimits: false, createPerMin: 10, joinPerMin: 60, signalPerSec: 20, socketsPerIp: 32, openLobbiesPerIp: 8, idleTimeoutMs: 30_000,
|
|
23
25
|
resumeWindowMs: 60_000, codeReuseMs: 24 * 3600 * 1000, listLimitMax: 100, singleLobby: false, password: null, issueCodes: true,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
|
-
/** Settings for a hub inside the host's game (docs/direct-ip.md s.2): no codes, no list, no ICE servers. */
|
|
28
|
+
/** Settings for a hub inside the host's game (docs/design/direct-ip.md s.2): no codes, no list, no ICE servers. */
|
|
27
29
|
export function directHubOptions(maxPlayers, password) {
|
|
28
30
|
const n = Math.min(250, Math.max(2, maxPlayers));
|
|
29
31
|
return {
|
|
@@ -32,6 +34,12 @@ export function directHubOptions(maxPlayers, password) {
|
|
|
32
34
|
maxLobbies: 1, hardMaxPlayers: n, maxPeers: n + 8,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
37
|
+
/** A session's total `signal` budget is this many targets' worth of `signalPerSec` (spec/signaling.md rate limits). */
|
|
38
|
+
export const SIGNAL_FANOUT = 8;
|
|
39
|
+
/** Hub sessions bridged from Nostr have the address `nostr:<pubkey prefix>`. */
|
|
40
|
+
export const NOSTR_ADDR = "nostr:";
|
|
41
|
+
/** `signal` per second per (sender, Nostr target): relays reject high publish rates. */
|
|
42
|
+
export const NOSTR_SIGNAL_PER_SEC = 10;
|
|
35
43
|
/** Sliding-window counter: at most `limit` events per `windowMs`. */
|
|
36
44
|
export class RateWindow {
|
|
37
45
|
limit;
|
|
@@ -105,7 +113,6 @@ export class Session {
|
|
|
105
113
|
connId;
|
|
106
114
|
send;
|
|
107
115
|
close;
|
|
108
|
-
signalRate;
|
|
109
116
|
closed = false;
|
|
110
117
|
lastSeen = 0;
|
|
111
118
|
helloDone = false;
|
|
@@ -116,14 +123,19 @@ export class Session {
|
|
|
116
123
|
member = null;
|
|
117
124
|
pendingIn = null;
|
|
118
125
|
pendingMember = null;
|
|
119
|
-
constructor(id, ip, connId, send, close
|
|
126
|
+
constructor(id, ip, connId, send, close) {
|
|
120
127
|
this.id = id;
|
|
121
128
|
this.ip = ip;
|
|
122
129
|
this.connId = connId;
|
|
123
130
|
this.send = send;
|
|
124
131
|
this.close = close;
|
|
125
|
-
this.signalRate = signalRate;
|
|
126
132
|
}
|
|
133
|
+
/** Nostr joiners: the remote address of the host's WebRTC link to this session, once known (kick bans it too). */
|
|
134
|
+
linkIp = null;
|
|
135
|
+
/** One window per target member (a member belongs to one lobby, so a new lobby starts fresh). */
|
|
136
|
+
signalRates = new Map();
|
|
137
|
+
/** All signals of this session, so the per-target budget does not grow without bound with the lobby. */
|
|
138
|
+
signalTotal = null;
|
|
127
139
|
}
|
|
128
140
|
class Member {
|
|
129
141
|
peerId = 0;
|
|
@@ -133,6 +145,7 @@ class Member {
|
|
|
133
145
|
detachedAt = 0;
|
|
134
146
|
token = "";
|
|
135
147
|
ip = "";
|
|
148
|
+
renames = null;
|
|
136
149
|
}
|
|
137
150
|
class HubLobby {
|
|
138
151
|
code = "";
|
|
@@ -162,7 +175,7 @@ class HubLobby {
|
|
|
162
175
|
return this.members.find((m) => m.peerId === peerId);
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
|
-
const KNOWN_TYPES = new Set(["hello", "create", "join", "list", "leave", "signal", "meta", "phase", "kick", "approve", "ping", "host_changed", "path"]);
|
|
178
|
+
const KNOWN_TYPES = new Set(["hello", "create", "join", "list", "leave", "signal", "meta", "phase", "kick", "approve", "ping", "host_changed", "path", "set_name"]);
|
|
166
179
|
const str = (o, k) => (typeof o[k] === "string" ? o[k] : null);
|
|
167
180
|
const int = (o, k) => {
|
|
168
181
|
const v = o[k];
|
|
@@ -171,7 +184,7 @@ const int = (o, k) => {
|
|
|
171
184
|
const bool = (o, k) => (typeof o[k] === "boolean" ? o[k] : null);
|
|
172
185
|
const has = (o, k) => o[k] !== undefined && o[k] !== null;
|
|
173
186
|
const field = (name) => ({ field: name });
|
|
174
|
-
const hex = (n) =>
|
|
187
|
+
const hex = (n) => randomHex(n);
|
|
175
188
|
const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
176
189
|
/** Not sanitised (spec/signaling.md): over the limits or non-string values are rejected. */
|
|
177
190
|
function readMeta(node) {
|
|
@@ -208,11 +221,22 @@ export class SignalingHub {
|
|
|
208
221
|
createdOnce = false;
|
|
209
222
|
members = 0;
|
|
210
223
|
logSalt = hex(8);
|
|
224
|
+
metrics = new Metrics();
|
|
211
225
|
constructor(options = {}, deps = {}) {
|
|
212
226
|
this.o = { ...defaultHubOptions(), ...options };
|
|
213
227
|
this.now = deps.now ?? (() => Date.now());
|
|
214
|
-
this.rand = deps.randomInt ?? ((n) =>
|
|
228
|
+
this.rand = deps.randomInt ?? ((n) => randomBelow(n));
|
|
215
229
|
this.log = deps.log ?? (() => { });
|
|
230
|
+
const m = this.metrics;
|
|
231
|
+
m.gauge("onelobby_lobbies", "Live lobbies.", () => this.lobbyCount);
|
|
232
|
+
m.gauge("onelobby_sockets", "Open signaling sockets.", () => this.socketCount);
|
|
233
|
+
m.gauge("onelobby_lobbies_retired", "Join codes held back from reuse.", () => this.retired.size);
|
|
234
|
+
m.describe("onelobby_messages_total", "Client messages received, by type.");
|
|
235
|
+
m.describe("onelobby_errors_total", "Error replies sent, by code.");
|
|
236
|
+
m.describe("onelobby_path_total", "ICE path reports from peers: direct vs relayed (DESIGN s.10).");
|
|
237
|
+
m.describe("onelobby_connections_total", "Sockets accepted since start.");
|
|
238
|
+
m.describe("onelobby_lobbies_created_total", "Lobbies created since start.");
|
|
239
|
+
m.describe("onelobby_rate_limit_hits_total", "Requests refused by a rate limit, by kind.");
|
|
216
240
|
}
|
|
217
241
|
get lobbyCount() {
|
|
218
242
|
return this.lobbies.size;
|
|
@@ -226,15 +250,22 @@ export class SignalingHub {
|
|
|
226
250
|
// ---------------------------------------------------------------- connection lifecycle
|
|
227
251
|
/** Registers a socket. Returns null (after sending server_full and closing) when over maxPeers. */
|
|
228
252
|
connect(ip, send, close) {
|
|
229
|
-
const s = new Session(++this.nextSession, ip, hex(6), send, close
|
|
253
|
+
const s = new Session(++this.nextSession, ip, hex(6), send, close);
|
|
230
254
|
s.lastSeen = this.now();
|
|
255
|
+
this.metrics.inc("onelobby_connections_total");
|
|
231
256
|
if (this.sessions.size >= this.o.maxPeers) {
|
|
232
257
|
this.error(s, "server_full", "server is at its peer limit");
|
|
233
258
|
s.close();
|
|
234
259
|
return null;
|
|
235
260
|
}
|
|
261
|
+
const st = this.ipOf(ip);
|
|
262
|
+
if (!this.o.disableIpLimits && st.sockets >= this.o.socketsPerIp * this.scale) {
|
|
263
|
+
this.rateLimited(s, "sockets", 5000, "max_sockets");
|
|
264
|
+
s.close(1008, "too_many_sockets");
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
236
267
|
this.sessions.set(s.id, s);
|
|
237
|
-
|
|
268
|
+
st.sockets++;
|
|
238
269
|
return s;
|
|
239
270
|
}
|
|
240
271
|
disconnect(s) {
|
|
@@ -259,10 +290,30 @@ export class SignalingHub {
|
|
|
259
290
|
s.lobby = null;
|
|
260
291
|
this.logEvent("detach", lobby, s);
|
|
261
292
|
this.broadcast(lobby, { t: "peer_left", peerId: m.peerId, reason: "timeout" });
|
|
293
|
+
this.metrics.inc("onelobby_peers_left_total", "reason", "timeout");
|
|
262
294
|
if (lobby.hostId === m.peerId)
|
|
263
295
|
this.promoteHost(lobby, m.peerId);
|
|
264
296
|
}
|
|
265
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* The host's WebRTC link to joiner `peerId` has the remote address `ip` (selected candidate pair; the host's own
|
|
300
|
+
* lobby reports it, a relayed or mDNS address must not be passed). Only Nostr sessions take it: their socket address
|
|
301
|
+
* is a pubkey the joiner chose. A joiner whose link address is banned is removed as kicked; a later kick bans it.
|
|
302
|
+
*/
|
|
303
|
+
reportLinkAddress(peerId, ip) {
|
|
304
|
+
for (const lobby of this.lobbies.values()) {
|
|
305
|
+
const mem = lobby.find(peerId);
|
|
306
|
+
const sess = mem?.session;
|
|
307
|
+
if (!mem || !sess || !sess.ip.startsWith(NOSTR_ADDR))
|
|
308
|
+
continue;
|
|
309
|
+
sess.linkIp = ip;
|
|
310
|
+
if (lobby.kicked.has(ip)) {
|
|
311
|
+
this.removeMember(lobby, mem, "kicked");
|
|
312
|
+
this.closeSession(sess);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
266
317
|
closeSession(s) {
|
|
267
318
|
s.close();
|
|
268
319
|
this.disconnect(s);
|
|
@@ -272,8 +323,10 @@ export class SignalingHub {
|
|
|
272
323
|
tick() {
|
|
273
324
|
const now = this.now();
|
|
274
325
|
for (const s of [...this.sessions.values()])
|
|
275
|
-
if (now - s.lastSeen > this.o.idleTimeoutMs)
|
|
326
|
+
if (now - s.lastSeen > this.o.idleTimeoutMs) {
|
|
327
|
+
this.metrics.inc("onelobby_idle_timeouts_total");
|
|
276
328
|
this.closeSession(s);
|
|
329
|
+
}
|
|
277
330
|
for (const lobby of [...this.lobbies.values()])
|
|
278
331
|
for (const m of [...lobby.members])
|
|
279
332
|
if (m.detachedAt > 0 && now - m.detachedAt >= this.o.resumeWindowMs)
|
|
@@ -308,6 +361,7 @@ export class SignalingHub {
|
|
|
308
361
|
this.error(s, "bad_request", "expected a JSON object with a string t", field("t"));
|
|
309
362
|
return;
|
|
310
363
|
}
|
|
364
|
+
this.metrics.inc("onelobby_messages_total", "type", KNOWN_TYPES.has(t) ? t : "unknown");
|
|
311
365
|
if (t === "hello")
|
|
312
366
|
return this.hello(s, msg);
|
|
313
367
|
if (!s.helloDone) {
|
|
@@ -333,6 +387,7 @@ export class SignalingHub {
|
|
|
333
387
|
}
|
|
334
388
|
case "host_changed": return this.claimHost(s, msg);
|
|
335
389
|
case "path": return this.pathReport(s, msg);
|
|
390
|
+
case "set_name": return this.setName(s, msg);
|
|
336
391
|
default: return; // unknown types are ignored
|
|
337
392
|
}
|
|
338
393
|
}
|
|
@@ -386,6 +441,7 @@ export class SignalingHub {
|
|
|
386
441
|
ice: this.iceList(s),
|
|
387
442
|
serverTime: this.now(),
|
|
388
443
|
limits: { maxMsg: this.o.maxMessageBytes, maxPlayers: this.o.hardMaxPlayers },
|
|
444
|
+
features: ["set_name"],
|
|
389
445
|
};
|
|
390
446
|
if (resumed)
|
|
391
447
|
w.peerId = resumed.peerId;
|
|
@@ -399,6 +455,7 @@ export class SignalingHub {
|
|
|
399
455
|
s.lobby = rl;
|
|
400
456
|
s.member = resumed;
|
|
401
457
|
this.logEvent("resume", rl, s);
|
|
458
|
+
this.metrics.inc("onelobby_resumes_total");
|
|
402
459
|
for (const other of rl.members)
|
|
403
460
|
if (other !== resumed)
|
|
404
461
|
this.sendTo(other, { t: "peer_joined", peer: this.peerObj(resumed) });
|
|
@@ -412,7 +469,7 @@ export class SignalingHub {
|
|
|
412
469
|
if (this.o.turnUrls.length > 0 && this.o.turnSecret.length > 0) {
|
|
413
470
|
const expiry = Math.floor(this.now() / 1000) + this.o.turnTtlSeconds;
|
|
414
471
|
const username = `${expiry}:${s.connId}`;
|
|
415
|
-
const credential =
|
|
472
|
+
const credential = turnCredential(this.o.turnSecret, username);
|
|
416
473
|
ice.push({ urls: [...this.o.turnUrls], username, credential, ttl: this.o.turnTtlSeconds });
|
|
417
474
|
}
|
|
418
475
|
return ice;
|
|
@@ -482,6 +539,7 @@ export class SignalingHub {
|
|
|
482
539
|
this.createdOnce = true;
|
|
483
540
|
this.gameLobbies.set(s.gameId, (this.gameLobbies.get(s.gameId) ?? 0) + 1);
|
|
484
541
|
ip.open++;
|
|
542
|
+
this.metrics.inc("onelobby_lobbies_created_total");
|
|
485
543
|
const host = this.addMember(lobby, s, null);
|
|
486
544
|
lobby.hostId = host.peerId;
|
|
487
545
|
this.logEvent("create", lobby, s);
|
|
@@ -632,13 +690,20 @@ export class SignalingHub {
|
|
|
632
690
|
const me = s.member;
|
|
633
691
|
if (!lobby || !me)
|
|
634
692
|
return this.error(s, "bad_request", "not in a lobby");
|
|
635
|
-
const now = this.now();
|
|
636
|
-
if (!this.o.disableIpLimits && !s.signalRate.tryHit(now))
|
|
637
|
-
return this.rateLimited(s, "signal", s.signalRate.retryAfterMs(now), null);
|
|
638
693
|
const to = int(m, "to");
|
|
639
694
|
const target = to === null ? undefined : lobby.find(to);
|
|
640
695
|
if (!target || !target.session || target === me)
|
|
641
696
|
return this.error(s, "bad_request", "unknown signal target", field("to"));
|
|
697
|
+
// a direct hub has no migration: the only peer links are host <-> joiner, so a joiner cannot dial another joiner
|
|
698
|
+
if (this.o.singleLobby && me.peerId !== lobby.hostId && target.peerId !== lobby.hostId)
|
|
699
|
+
return this.error(s, "bad_request", "signals go to or from the host only", field("to"));
|
|
700
|
+
const now = this.now();
|
|
701
|
+
const rate = this.signalRate(s, target, now);
|
|
702
|
+
if (!rate.tryHit(now))
|
|
703
|
+
return this.rateLimited(s, "signal", rate.retryAfterMs(now), null);
|
|
704
|
+
const total = (s.signalTotal ??= new RateWindow(this.o.signalPerSec * SIGNAL_FANOUT * this.scale, 1000));
|
|
705
|
+
if (!total.tryHit(now))
|
|
706
|
+
return this.rateLimited(s, "signal", total.retryAfterMs(now), null);
|
|
642
707
|
if (!has(m, "sdp") && !has(m, "ice"))
|
|
643
708
|
return this.error(s, "bad_request", "signal needs sdp or ice", field("sdp"));
|
|
644
709
|
const o = { t: "signal", from: me.peerId };
|
|
@@ -647,6 +712,23 @@ export class SignalingHub {
|
|
|
647
712
|
if (has(m, "ice"))
|
|
648
713
|
o.ice = m.ice;
|
|
649
714
|
this.send(target.session, o);
|
|
715
|
+
this.metrics.inc("onelobby_signals_relayed_total");
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* spec/signaling.md rate limits: `signal` is counted per sender and target, so a host answering several joiners at once
|
|
719
|
+
* gets the budget for each, and per sender in total (SIGNAL_FANOUT targets' worth).
|
|
720
|
+
*/
|
|
721
|
+
signalRate(s, to, now) {
|
|
722
|
+
let w = s.signalRates.get(to);
|
|
723
|
+
if (!w) {
|
|
724
|
+
for (const [k, v] of s.signalRates)
|
|
725
|
+
if (v.isIdle(now))
|
|
726
|
+
s.signalRates.delete(k);
|
|
727
|
+
// relays refuse high publish rates, so a Nostr-bridged target gets half the budget
|
|
728
|
+
w = new RateWindow((to.session?.ip.startsWith(NOSTR_ADDR) ? NOSTR_SIGNAL_PER_SEC : this.o.signalPerSec) * this.scale, 1000);
|
|
729
|
+
s.signalRates.set(to, w);
|
|
730
|
+
}
|
|
731
|
+
return w;
|
|
650
732
|
}
|
|
651
733
|
requireHost(s) {
|
|
652
734
|
if (!s.lobby || !s.member) {
|
|
@@ -669,6 +751,22 @@ export class SignalingHub {
|
|
|
669
751
|
lobby.meta = meta.meta;
|
|
670
752
|
this.broadcastLobby(lobby);
|
|
671
753
|
}
|
|
754
|
+
setName(s, m) {
|
|
755
|
+
if (!s.lobby || !s.member)
|
|
756
|
+
return this.error(s, "bad_request", "not in a lobby");
|
|
757
|
+
const name = typeof m.name === "string" ? sanitize(m.name, NAME_MAX) : "";
|
|
758
|
+
if (!name)
|
|
759
|
+
return this.error(s, "bad_request", "name must be a non-empty string", field("name"));
|
|
760
|
+
if (name === s.member.name)
|
|
761
|
+
return;
|
|
762
|
+
const now = this.now();
|
|
763
|
+
const rate = (s.member.renames ??= new RateWindow(this.scale, 1000));
|
|
764
|
+
if (!rate.tryHit(now))
|
|
765
|
+
return this.rateLimited(s, "set_name", rate.retryAfterMs(now), null);
|
|
766
|
+
s.member.name = name;
|
|
767
|
+
s.name = name;
|
|
768
|
+
this.broadcastLobby(s.lobby);
|
|
769
|
+
}
|
|
672
770
|
hostPhase(s, m) {
|
|
673
771
|
const lobby = this.requireHost(s);
|
|
674
772
|
if (!lobby)
|
|
@@ -705,8 +803,13 @@ export class SignalingHub {
|
|
|
705
803
|
data.closeCode = code;
|
|
706
804
|
if (closeReason !== null)
|
|
707
805
|
data.closeReason = closeReason;
|
|
708
|
-
if (ban !== false)
|
|
806
|
+
if (ban !== false) {
|
|
709
807
|
lobby.kicked.add(target.ip);
|
|
808
|
+
// a Nostr joiner picks its own key, so also ban the address its WebRTC link came from, when the host saw it
|
|
809
|
+
const link = target.session?.linkIp;
|
|
810
|
+
if (link && target.ip.startsWith(NOSTR_ADDR))
|
|
811
|
+
lobby.kicked.add(link);
|
|
812
|
+
}
|
|
710
813
|
this.removeMember(lobby, target, ban === false ? "disconnected" : "kicked", Object.keys(data).length ? data : undefined);
|
|
711
814
|
}
|
|
712
815
|
approve(s, m) {
|
|
@@ -755,11 +858,14 @@ export class SignalingHub {
|
|
|
755
858
|
lobby.epoch = epoch;
|
|
756
859
|
lobby.pendingClaimPeer = lobby.pendingClaimEpoch = 0;
|
|
757
860
|
this.broadcast(lobby, { t: "host_changed", hostId: lobby.hostId, epoch: lobby.epoch });
|
|
861
|
+
this.metrics.inc("onelobby_host_changes_total");
|
|
758
862
|
this.logEvent("host_claim", lobby, s);
|
|
759
863
|
}
|
|
760
864
|
pathReport(s, m) {
|
|
761
865
|
const p = str(m, "path");
|
|
762
|
-
if (p
|
|
866
|
+
if (p === "relayed" || p === "direct")
|
|
867
|
+
this.metrics.inc("onelobby_path_total", "path", p);
|
|
868
|
+
else
|
|
763
869
|
this.error(s, "bad_request", "path must be relayed or direct", field("path"));
|
|
764
870
|
}
|
|
765
871
|
// ---------------------------------------------------------------- membership changes
|
|
@@ -779,6 +885,7 @@ export class SignalingHub {
|
|
|
779
885
|
}
|
|
780
886
|
if (wasConnected) {
|
|
781
887
|
this.broadcast(lobby, { t: "peer_left", peerId: mem.peerId, reason });
|
|
888
|
+
this.metrics.inc("onelobby_peers_left_total", "reason", reason);
|
|
782
889
|
if ((reason === "kicked" || reason === "disconnected") && ms)
|
|
783
890
|
this.error(ms, "kicked", reason === "kicked" ? "the host removed you from the lobby" : "the host disconnected you", kickData);
|
|
784
891
|
}
|
|
@@ -811,6 +918,7 @@ export class SignalingHub {
|
|
|
811
918
|
else
|
|
812
919
|
lobby.epoch++;
|
|
813
920
|
this.broadcast(lobby, { t: "host_changed", hostId: next.peerId, epoch: lobby.epoch });
|
|
921
|
+
this.metrics.inc("onelobby_host_changes_total");
|
|
814
922
|
const hs = next.session;
|
|
815
923
|
if (hs)
|
|
816
924
|
for (const pm of lobby.pending)
|
|
@@ -884,12 +992,14 @@ export class SignalingHub {
|
|
|
884
992
|
this.send(m.session, o);
|
|
885
993
|
}
|
|
886
994
|
error(s, code, msg, data) {
|
|
995
|
+
this.metrics.inc("onelobby_errors_total", "code", code);
|
|
887
996
|
const o = { t: "error", code, msg };
|
|
888
997
|
if (data !== undefined)
|
|
889
998
|
o.data = data;
|
|
890
999
|
s.send(JSON.stringify(o));
|
|
891
1000
|
}
|
|
892
1001
|
rateLimited(s, kind, retryAfterMs, reason) {
|
|
1002
|
+
this.metrics.inc("onelobby_rate_limit_hits_total", "kind", kind);
|
|
893
1003
|
this.log(`rate_limit kind=${kind} ip=${this.hashIp(s.ip)}`);
|
|
894
1004
|
const data = { retryAfterMs };
|
|
895
1005
|
if (reason !== null)
|
|
@@ -900,7 +1010,7 @@ export class SignalingHub {
|
|
|
900
1010
|
this.log(`lobby ${ev} code=${l.code} game=${l.gameId} players=${l.members.length} ip=${s ? this.hashIp(s.ip) : "-"}`);
|
|
901
1011
|
}
|
|
902
1012
|
hashIp(ip) {
|
|
903
|
-
return
|
|
1013
|
+
return shortHash(this.logSalt + ip);
|
|
904
1014
|
}
|
|
905
1015
|
}
|
|
906
1016
|
//# sourceMappingURL=hub.js.map
|