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.
Files changed (109) 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 +39 -10
  19. package/dist/direct/hub.js +134 -24
  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 +24 -0
  36. package/dist/friendly.js +30 -0
  37. package/dist/friendly.js.map +1 -1
  38. package/dist/index.d.ts +8 -4
  39. package/dist/index.js +6 -4
  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 +94 -5
  51. package/dist/lobby.js +177 -18
  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 +33 -0
  63. package/dist/nostr/limits.js +38 -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 +325 -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 +2673 -60
  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 +77 -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/signaling.d.ts +2 -0
  95. package/dist/signaling.js.map +1 -1
  96. package/dist/socket.d.ts +15 -6
  97. package/dist/socket.js.map +1 -1
  98. package/dist/testing.d.ts +2 -0
  99. package/dist/testing.js +18 -2
  100. package/dist/testing.js.map +1 -1
  101. package/dist/transport.d.ts +5 -1
  102. package/dist/transport.js.map +1 -1
  103. package/dist/webrtc.d.ts +6 -0
  104. package/dist/webrtc.js +27 -3
  105. package/dist/webrtc.js.map +1 -1
  106. package/package.json +1 -1
  107. package/dist/deprecated.d.ts +0 -18
  108. package/dist/deprecated.js +0 -11
  109. 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"]}
@@ -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;
@@ -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 {};
@@ -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;
@@ -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) => randomBytes(n).toString("hex");
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) => 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, new RateWindow(this.o.signalPerSec * this.scale, 1000));
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
- this.ipOf(ip).sockets++;
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 = createHmac("sha1", this.o.turnSecret).update(username).digest("base64");
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 !== "relayed" && p !== "direct")
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 createHash("sha256").update(this.logSalt + ip).digest("hex").slice(0, 8);
1013
+ return shortHash(this.logSalt + ip);
904
1014
  }
905
1015
  }
906
1016
  //# sourceMappingURL=hub.js.map