onelobby 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +2 -2
  2. package/blueprint.schema.json +8 -1
  3. package/dist/blueprint.d.ts +4 -0
  4. package/dist/blueprint.js +5 -0
  5. package/dist/blueprint.js.map +1 -1
  6. package/dist/codec.d.ts +23 -0
  7. package/dist/codec.js +28 -0
  8. package/dist/codec.js.map +1 -1
  9. package/dist/direct/browser-host.d.ts +46 -0
  10. package/dist/direct/browser-host.js +93 -0
  11. package/dist/direct/browser-host.js.map +1 -0
  12. package/dist/direct/hub-host.d.ts +34 -23
  13. package/dist/direct/hub-host.js +75 -59
  14. package/dist/direct/hub-host.js.map +1 -1
  15. package/dist/direct/hub-local.d.ts +22 -0
  16. package/dist/direct/hub-local.js +46 -0
  17. package/dist/direct/hub-local.js.map +1 -0
  18. package/dist/direct/hub.d.ts +37 -10
  19. package/dist/direct/hub.js +114 -23
  20. package/dist/direct/hub.js.map +1 -1
  21. package/dist/direct/index.d.ts +36 -3
  22. package/dist/direct/index.js +146 -20
  23. package/dist/direct/index.js.map +1 -1
  24. package/dist/direct/metrics.d.ts +12 -0
  25. package/dist/direct/metrics.js +45 -0
  26. package/dist/direct/metrics.js.map +1 -0
  27. package/dist/direct/portmap.d.ts +9 -2
  28. package/dist/direct/portmap.js +7 -2
  29. package/dist/direct/portmap.js.map +1 -1
  30. package/dist/direct/sha1.d.ts +16 -0
  31. package/dist/direct/sha1.js +111 -0
  32. package/dist/direct/sha1.js.map +1 -0
  33. package/dist/direct-address.d.ts +2 -2
  34. package/dist/direct-address.js.map +1 -1
  35. package/dist/friendly.d.ts +23 -0
  36. package/dist/friendly.js +28 -0
  37. package/dist/friendly.js.map +1 -1
  38. package/dist/index.d.ts +8 -2
  39. package/dist/index.js +6 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/internals.d.ts +9 -0
  42. package/dist/internals.js +9 -0
  43. package/dist/internals.js.map +1 -1
  44. package/dist/join-code-kind.d.ts +10 -0
  45. package/dist/join-code-kind.js +10 -0
  46. package/dist/join-code-kind.js.map +1 -0
  47. package/dist/joincode.d.ts +10 -1
  48. package/dist/joincode.js +23 -3
  49. package/dist/joincode.js.map +1 -1
  50. package/dist/lobby.d.ts +65 -4
  51. package/dist/lobby.js +151 -12
  52. package/dist/lobby.js.map +1 -1
  53. package/dist/lockstep.d.ts +8 -0
  54. package/dist/lockstep.js +23 -2
  55. package/dist/lockstep.js.map +1 -1
  56. package/dist/nostr/crypto.d.ts +20 -0
  57. package/dist/nostr/crypto.js +78 -0
  58. package/dist/nostr/crypto.js.map +1 -0
  59. package/dist/nostr/host.d.ts +66 -0
  60. package/dist/nostr/host.js +196 -0
  61. package/dist/nostr/host.js.map +1 -0
  62. package/dist/nostr/limits.d.ts +31 -0
  63. package/dist/nostr/limits.js +36 -0
  64. package/dist/nostr/limits.js.map +1 -0
  65. package/dist/nostr/link.d.ts +70 -0
  66. package/dist/nostr/link.js +180 -0
  67. package/dist/nostr/link.js.map +1 -0
  68. package/dist/nostr/relays.d.ts +79 -0
  69. package/dist/nostr/relays.js +315 -0
  70. package/dist/nostr/relays.js.map +1 -0
  71. package/dist/nostr/room.d.ts +43 -0
  72. package/dist/nostr/room.js +104 -0
  73. package/dist/nostr/room.js.map +1 -0
  74. package/dist/nostr/secp256k1.d.ts +10 -0
  75. package/dist/nostr/secp256k1.js +171 -0
  76. package/dist/nostr/secp256k1.js.map +1 -0
  77. package/dist/nostr/socket.d.ts +48 -0
  78. package/dist/nostr/socket.js +141 -0
  79. package/dist/nostr/socket.js.map +1 -0
  80. package/dist/onelobby.bundle.js +146 -0
  81. package/dist/onelobby.bundle.js.map +7 -0
  82. package/dist/onelobby.bundle.mjs +11 -5
  83. package/dist/onelobby.bundle.mjs.map +4 -4
  84. package/dist/onelobby.d.ts +31 -3
  85. package/dist/onelobby.global.js +2599 -46
  86. package/dist/onelobby.global.js.map +4 -4
  87. package/dist/onelobby.global.min.js +11 -5
  88. package/dist/onelobby.global.min.js.map +4 -4
  89. package/dist/onelobby.js +68 -14
  90. package/dist/onelobby.js.map +1 -1
  91. package/dist/pause.d.ts +85 -0
  92. package/dist/pause.js +160 -0
  93. package/dist/pause.js.map +1 -0
  94. package/dist/transport.d.ts +3 -1
  95. package/dist/transport.js.map +1 -1
  96. package/dist/webrtc.d.ts +1 -0
  97. package/dist/webrtc.js +6 -1
  98. package/dist/webrtc.js.map +1 -1
  99. package/package.json +1 -1
@@ -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"]}
@@ -1,10 +1,13 @@
1
1
  /**
2
- * The signaling hub (protocol lk1) in TypeScript, for a Node process that hosts direct-IP lobbies
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 are left out (that is onelobby-server's job); everything else (codes, list, resume, private lobbies,
6
- * kicks, migration claims, TURN credentials, rate limits) behaves as in C#.
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, signalRate: RateWindow);
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;
@@ -132,13 +148,20 @@ export declare class SignalingHub {
132
148
  private createdOnce;
133
149
  private members;
134
150
  private readonly logSalt;
151
+ readonly metrics: Metrics;
135
152
  constructor(options?: Partial<HubOptions>, deps?: HubDeps);
136
153
  get lobbyCount(): number;
137
154
  get socketCount(): number;
138
155
  private get scale();
139
156
  /** 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;
157
+ connect(ip: string, send: (text: string) => void, close: (code?: number, reason?: string) => void): Session | null;
141
158
  disconnect(s: Session): void;
159
+ /**
160
+ * The host's WebRTC link to joiner `peerId` has the remote address `ip` (selected candidate pair; the host's own
161
+ * lobby reports it, a relayed or mDNS address must not be passed). Only Nostr sessions take it: their socket address
162
+ * is a pubkey the joiner chose. A joiner whose link address is banned is removed as kicked; a later kick bans it.
163
+ */
164
+ reportLinkAddress(peerId: number, ip: string): void;
142
165
  private closeSession;
143
166
  /** Call about once a second: idle timeouts, resume-window expiry, code retirement. */
144
167
  tick(): void;
@@ -153,6 +176,11 @@ export declare class SignalingHub {
153
176
  private list;
154
177
  private leave;
155
178
  private signal;
179
+ /**
180
+ * spec/signaling.md rate limits: `signal` is counted per sender and target, so a host answering several joiners at once
181
+ * gets the budget for each, and per sender in total (SIGNAL_FANOUT targets' worth).
182
+ */
183
+ private signalRate;
156
184
  private requireHost;
157
185
  private hostMeta;
158
186
  private hostPhase;
@@ -178,4 +206,3 @@ export declare class SignalingHub {
178
206
  private logEvent;
179
207
  private hashIp;
180
208
  }
181
- export {};
@@ -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-IP lobbies
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 are left out (that is onelobby-server's job); everything else (codes, list, resume, private lobbies,
8
- * kicks, migration claims, TURN credentials, rate limits) behaves as in C#.
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, signalRate) {
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;
@@ -171,7 +183,7 @@ const int = (o, k) => {
171
183
  const bool = (o, k) => (typeof o[k] === "boolean" ? o[k] : null);
172
184
  const has = (o, k) => o[k] !== undefined && o[k] !== null;
173
185
  const field = (name) => ({ field: name });
174
- const hex = (n) => randomBytes(n).toString("hex");
186
+ const hex = (n) => randomHex(n);
175
187
  const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
176
188
  /** Not sanitised (spec/signaling.md): over the limits or non-string values are rejected. */
177
189
  function readMeta(node) {
@@ -208,11 +220,22 @@ export class SignalingHub {
208
220
  createdOnce = false;
209
221
  members = 0;
210
222
  logSalt = hex(8);
223
+ metrics = new Metrics();
211
224
  constructor(options = {}, deps = {}) {
212
225
  this.o = { ...defaultHubOptions(), ...options };
213
226
  this.now = deps.now ?? (() => Date.now());
214
- this.rand = deps.randomInt ?? ((n) => randomInt(n));
227
+ this.rand = deps.randomInt ?? ((n) => randomBelow(n));
215
228
  this.log = deps.log ?? (() => { });
229
+ const m = this.metrics;
230
+ m.gauge("onelobby_lobbies", "Live lobbies.", () => this.lobbyCount);
231
+ m.gauge("onelobby_sockets", "Open signaling sockets.", () => this.socketCount);
232
+ m.gauge("onelobby_lobbies_retired", "Join codes held back from reuse.", () => this.retired.size);
233
+ m.describe("onelobby_messages_total", "Client messages received, by type.");
234
+ m.describe("onelobby_errors_total", "Error replies sent, by code.");
235
+ m.describe("onelobby_path_total", "ICE path reports from peers: direct vs relayed (DESIGN s.10).");
236
+ m.describe("onelobby_connections_total", "Sockets accepted since start.");
237
+ m.describe("onelobby_lobbies_created_total", "Lobbies created since start.");
238
+ m.describe("onelobby_rate_limit_hits_total", "Requests refused by a rate limit, by kind.");
216
239
  }
217
240
  get lobbyCount() {
218
241
  return this.lobbies.size;
@@ -226,15 +249,22 @@ export class SignalingHub {
226
249
  // ---------------------------------------------------------------- connection lifecycle
227
250
  /** Registers a socket. Returns null (after sending server_full and closing) when over maxPeers. */
228
251
  connect(ip, send, close) {
229
- const s = new Session(++this.nextSession, ip, hex(6), send, close, new RateWindow(this.o.signalPerSec * this.scale, 1000));
252
+ const s = new Session(++this.nextSession, ip, hex(6), send, close);
230
253
  s.lastSeen = this.now();
254
+ this.metrics.inc("onelobby_connections_total");
231
255
  if (this.sessions.size >= this.o.maxPeers) {
232
256
  this.error(s, "server_full", "server is at its peer limit");
233
257
  s.close();
234
258
  return null;
235
259
  }
260
+ const st = this.ipOf(ip);
261
+ if (!this.o.disableIpLimits && st.sockets >= this.o.socketsPerIp * this.scale) {
262
+ this.rateLimited(s, "sockets", 5000, "max_sockets");
263
+ s.close(1008, "too_many_sockets");
264
+ return null;
265
+ }
236
266
  this.sessions.set(s.id, s);
237
- this.ipOf(ip).sockets++;
267
+ st.sockets++;
238
268
  return s;
239
269
  }
240
270
  disconnect(s) {
@@ -259,10 +289,30 @@ export class SignalingHub {
259
289
  s.lobby = null;
260
290
  this.logEvent("detach", lobby, s);
261
291
  this.broadcast(lobby, { t: "peer_left", peerId: m.peerId, reason: "timeout" });
292
+ this.metrics.inc("onelobby_peers_left_total", "reason", "timeout");
262
293
  if (lobby.hostId === m.peerId)
263
294
  this.promoteHost(lobby, m.peerId);
264
295
  }
265
296
  }
297
+ /**
298
+ * The host's WebRTC link to joiner `peerId` has the remote address `ip` (selected candidate pair; the host's own
299
+ * lobby reports it, a relayed or mDNS address must not be passed). Only Nostr sessions take it: their socket address
300
+ * is a pubkey the joiner chose. A joiner whose link address is banned is removed as kicked; a later kick bans it.
301
+ */
302
+ reportLinkAddress(peerId, ip) {
303
+ for (const lobby of this.lobbies.values()) {
304
+ const mem = lobby.find(peerId);
305
+ const sess = mem?.session;
306
+ if (!mem || !sess || !sess.ip.startsWith(NOSTR_ADDR))
307
+ continue;
308
+ sess.linkIp = ip;
309
+ if (lobby.kicked.has(ip)) {
310
+ this.removeMember(lobby, mem, "kicked");
311
+ this.closeSession(sess);
312
+ }
313
+ return;
314
+ }
315
+ }
266
316
  closeSession(s) {
267
317
  s.close();
268
318
  this.disconnect(s);
@@ -272,8 +322,10 @@ export class SignalingHub {
272
322
  tick() {
273
323
  const now = this.now();
274
324
  for (const s of [...this.sessions.values()])
275
- if (now - s.lastSeen > this.o.idleTimeoutMs)
325
+ if (now - s.lastSeen > this.o.idleTimeoutMs) {
326
+ this.metrics.inc("onelobby_idle_timeouts_total");
276
327
  this.closeSession(s);
328
+ }
277
329
  for (const lobby of [...this.lobbies.values()])
278
330
  for (const m of [...lobby.members])
279
331
  if (m.detachedAt > 0 && now - m.detachedAt >= this.o.resumeWindowMs)
@@ -308,6 +360,7 @@ export class SignalingHub {
308
360
  this.error(s, "bad_request", "expected a JSON object with a string t", field("t"));
309
361
  return;
310
362
  }
363
+ this.metrics.inc("onelobby_messages_total", "type", KNOWN_TYPES.has(t) ? t : "unknown");
311
364
  if (t === "hello")
312
365
  return this.hello(s, msg);
313
366
  if (!s.helloDone) {
@@ -399,6 +452,7 @@ export class SignalingHub {
399
452
  s.lobby = rl;
400
453
  s.member = resumed;
401
454
  this.logEvent("resume", rl, s);
455
+ this.metrics.inc("onelobby_resumes_total");
402
456
  for (const other of rl.members)
403
457
  if (other !== resumed)
404
458
  this.sendTo(other, { t: "peer_joined", peer: this.peerObj(resumed) });
@@ -412,7 +466,7 @@ export class SignalingHub {
412
466
  if (this.o.turnUrls.length > 0 && this.o.turnSecret.length > 0) {
413
467
  const expiry = Math.floor(this.now() / 1000) + this.o.turnTtlSeconds;
414
468
  const username = `${expiry}:${s.connId}`;
415
- const credential = createHmac("sha1", this.o.turnSecret).update(username).digest("base64");
469
+ const credential = turnCredential(this.o.turnSecret, username);
416
470
  ice.push({ urls: [...this.o.turnUrls], username, credential, ttl: this.o.turnTtlSeconds });
417
471
  }
418
472
  return ice;
@@ -482,6 +536,7 @@ export class SignalingHub {
482
536
  this.createdOnce = true;
483
537
  this.gameLobbies.set(s.gameId, (this.gameLobbies.get(s.gameId) ?? 0) + 1);
484
538
  ip.open++;
539
+ this.metrics.inc("onelobby_lobbies_created_total");
485
540
  const host = this.addMember(lobby, s, null);
486
541
  lobby.hostId = host.peerId;
487
542
  this.logEvent("create", lobby, s);
@@ -632,13 +687,20 @@ export class SignalingHub {
632
687
  const me = s.member;
633
688
  if (!lobby || !me)
634
689
  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
690
  const to = int(m, "to");
639
691
  const target = to === null ? undefined : lobby.find(to);
640
692
  if (!target || !target.session || target === me)
641
693
  return this.error(s, "bad_request", "unknown signal target", field("to"));
694
+ // a direct hub has no migration: the only peer links are host <-> joiner, so a joiner cannot dial another joiner
695
+ if (this.o.singleLobby && me.peerId !== lobby.hostId && target.peerId !== lobby.hostId)
696
+ return this.error(s, "bad_request", "signals go to or from the host only", field("to"));
697
+ const now = this.now();
698
+ const rate = this.signalRate(s, target, now);
699
+ if (!rate.tryHit(now))
700
+ return this.rateLimited(s, "signal", rate.retryAfterMs(now), null);
701
+ const total = (s.signalTotal ??= new RateWindow(this.o.signalPerSec * SIGNAL_FANOUT * this.scale, 1000));
702
+ if (!total.tryHit(now))
703
+ return this.rateLimited(s, "signal", total.retryAfterMs(now), null);
642
704
  if (!has(m, "sdp") && !has(m, "ice"))
643
705
  return this.error(s, "bad_request", "signal needs sdp or ice", field("sdp"));
644
706
  const o = { t: "signal", from: me.peerId };
@@ -647,6 +709,23 @@ export class SignalingHub {
647
709
  if (has(m, "ice"))
648
710
  o.ice = m.ice;
649
711
  this.send(target.session, o);
712
+ this.metrics.inc("onelobby_signals_relayed_total");
713
+ }
714
+ /**
715
+ * spec/signaling.md rate limits: `signal` is counted per sender and target, so a host answering several joiners at once
716
+ * gets the budget for each, and per sender in total (SIGNAL_FANOUT targets' worth).
717
+ */
718
+ signalRate(s, to, now) {
719
+ let w = s.signalRates.get(to);
720
+ if (!w) {
721
+ for (const [k, v] of s.signalRates)
722
+ if (v.isIdle(now))
723
+ s.signalRates.delete(k);
724
+ // relays refuse high publish rates, so a Nostr-bridged target gets half the budget
725
+ w = new RateWindow((to.session?.ip.startsWith(NOSTR_ADDR) ? NOSTR_SIGNAL_PER_SEC : this.o.signalPerSec) * this.scale, 1000);
726
+ s.signalRates.set(to, w);
727
+ }
728
+ return w;
650
729
  }
651
730
  requireHost(s) {
652
731
  if (!s.lobby || !s.member) {
@@ -705,8 +784,13 @@ export class SignalingHub {
705
784
  data.closeCode = code;
706
785
  if (closeReason !== null)
707
786
  data.closeReason = closeReason;
708
- if (ban !== false)
787
+ if (ban !== false) {
709
788
  lobby.kicked.add(target.ip);
789
+ // a Nostr joiner picks its own key, so also ban the address its WebRTC link came from, when the host saw it
790
+ const link = target.session?.linkIp;
791
+ if (link && target.ip.startsWith(NOSTR_ADDR))
792
+ lobby.kicked.add(link);
793
+ }
710
794
  this.removeMember(lobby, target, ban === false ? "disconnected" : "kicked", Object.keys(data).length ? data : undefined);
711
795
  }
712
796
  approve(s, m) {
@@ -755,11 +839,14 @@ export class SignalingHub {
755
839
  lobby.epoch = epoch;
756
840
  lobby.pendingClaimPeer = lobby.pendingClaimEpoch = 0;
757
841
  this.broadcast(lobby, { t: "host_changed", hostId: lobby.hostId, epoch: lobby.epoch });
842
+ this.metrics.inc("onelobby_host_changes_total");
758
843
  this.logEvent("host_claim", lobby, s);
759
844
  }
760
845
  pathReport(s, m) {
761
846
  const p = str(m, "path");
762
- if (p !== "relayed" && p !== "direct")
847
+ if (p === "relayed" || p === "direct")
848
+ this.metrics.inc("onelobby_path_total", "path", p);
849
+ else
763
850
  this.error(s, "bad_request", "path must be relayed or direct", field("path"));
764
851
  }
765
852
  // ---------------------------------------------------------------- membership changes
@@ -779,6 +866,7 @@ export class SignalingHub {
779
866
  }
780
867
  if (wasConnected) {
781
868
  this.broadcast(lobby, { t: "peer_left", peerId: mem.peerId, reason });
869
+ this.metrics.inc("onelobby_peers_left_total", "reason", reason);
782
870
  if ((reason === "kicked" || reason === "disconnected") && ms)
783
871
  this.error(ms, "kicked", reason === "kicked" ? "the host removed you from the lobby" : "the host disconnected you", kickData);
784
872
  }
@@ -811,6 +899,7 @@ export class SignalingHub {
811
899
  else
812
900
  lobby.epoch++;
813
901
  this.broadcast(lobby, { t: "host_changed", hostId: next.peerId, epoch: lobby.epoch });
902
+ this.metrics.inc("onelobby_host_changes_total");
814
903
  const hs = next.session;
815
904
  if (hs)
816
905
  for (const pm of lobby.pending)
@@ -884,12 +973,14 @@ export class SignalingHub {
884
973
  this.send(m.session, o);
885
974
  }
886
975
  error(s, code, msg, data) {
976
+ this.metrics.inc("onelobby_errors_total", "code", code);
887
977
  const o = { t: "error", code, msg };
888
978
  if (data !== undefined)
889
979
  o.data = data;
890
980
  s.send(JSON.stringify(o));
891
981
  }
892
982
  rateLimited(s, kind, retryAfterMs, reason) {
983
+ this.metrics.inc("onelobby_rate_limit_hits_total", "kind", kind);
893
984
  this.log(`rate_limit kind=${kind} ip=${this.hashIp(s.ip)}`);
894
985
  const data = { retryAfterMs };
895
986
  if (reason !== null)
@@ -900,7 +991,7 @@ export class SignalingHub {
900
991
  this.log(`lobby ${ev} code=${l.code} game=${l.gameId} players=${l.members.length} ip=${s ? this.hashIp(s.ip) : "-"}`);
901
992
  }
902
993
  hashIp(ip) {
903
- return createHash("sha256").update(this.logSalt + ip).digest("hex").slice(0, 8);
994
+ return shortHash(this.logSalt + ip);
904
995
  }
905
996
  }
906
997
  //# sourceMappingURL=hub.js.map