@honeybbq/teamspeak-client 1.0.0-beta.2 → 1.0.0-beta.4

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 (45) hide show
  1. package/README.md +61 -8
  2. package/dist/capabilities.d.ts +13 -1
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/client.d.ts +17 -11
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/discovery/resolver.d.ts.map +1 -1
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/handshake/crypt-handshake.d.ts +9 -1
  10. package/dist/handshake/crypt-handshake.d.ts.map +1 -1
  11. package/dist/handshake.d.ts.map +1 -1
  12. package/dist/hooks.d.ts +7 -0
  13. package/dist/hooks.d.ts.map +1 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +762 -185
  17. package/dist/index.js.map +1 -1
  18. package/dist/notifications.d.ts +1 -1
  19. package/dist/notifications.d.ts.map +1 -1
  20. package/dist/trace.d.ts +223 -0
  21. package/dist/trace.d.ts.map +1 -0
  22. package/dist/transfer.d.ts +80 -1
  23. package/dist/transfer.d.ts.map +1 -1
  24. package/dist/transport/handler.d.ts +2 -0
  25. package/dist/transport/handler.d.ts.map +1 -1
  26. package/dist/types.d.ts +17 -110
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +11 -5
  29. package/src/capabilities.ts +23 -2
  30. package/src/client.ts +392 -54
  31. package/src/crypto/crypt.ts +5 -5
  32. package/src/discovery/resolver.ts +56 -3
  33. package/src/errors.ts +92 -11
  34. package/src/handshake/crypt-handshake.ts +26 -5
  35. package/src/handshake/crypt-init2.ts +5 -5
  36. package/src/handshake.ts +3 -1
  37. package/src/hooks.ts +30 -0
  38. package/src/index.ts +30 -10
  39. package/src/notifications.ts +1 -1
  40. package/src/throttle.ts +2 -2
  41. package/src/trace.ts +238 -0
  42. package/src/transfer.ts +334 -25
  43. package/src/transport/handler.ts +13 -9
  44. package/src/transport/packet.ts +1 -1
  45. package/src/types.ts +21 -123
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { CommandTimeoutError, ErrorCode, NotConnectedError, Reason, ServerError, TeamspeakError, asChannelID, asClientID, asUID, noopLogger } from "@honeybbq/teamspeak-core";
1
+ import { CommandTimeoutError, ErrorCode, NotConnectedError, Reason, ServerError, TeamspeakError, asChannelID, asClientID, asServerID, asUID, noopLogger } from "@honeybbq/teamspeak-core";
2
2
  import { createCipheriv, createECDH, createHash, createPrivateKey, createPublicKey, createSign, createVerify, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { buildCommand, buildCommandOrdered, encodeRequest, parseClient, parseCommand, parseErrorCode, parseIconID, parseInt10, parseServerError, parseUint16, parseUint64, responseSpec, sleep, splitCommandRows } from "@honeybbq/teamspeak-core/internal";
3
4
  import { ed25519 } from "@noble/curves/ed25519.js";
4
5
  import { lookup, resolveSrv } from "node:dns/promises";
5
6
  import { connect, createConnection, isIP } from "node:net";
6
7
  import { createSocket } from "node:dgram";
7
- import { buildCommand, buildCommandOrdered, encodeRequest, parseClient, parseCommand, parseErrorCode, parseIconID, parseInt10, parseServerError, parseUint16, parseUint64, responseSpec, sleep, splitCommandRows } from "@honeybbq/teamspeak-core/internal";
8
8
  import { pipeline } from "node:stream/promises";
9
+ import { Transform } from "node:stream";
9
10
  import { channel, tracingChannel } from "node:diagnostics_channel";
10
11
  export * from "@honeybbq/teamspeak-core";
11
12
  /** Where a client is in its connection's life. */
@@ -17,15 +18,56 @@ var ClientStatus = /* @__PURE__ */ Object.freeze({
17
18
  /** Connected: the handshake completed and commands can be sent. */
18
19
  Connected: "connected"
19
20
  });
21
+ /** Where a connection attempt can fail; see {@link AttemptFailedInfo}. */
22
+ var ConnectPhase = /* @__PURE__ */ Object.freeze({
23
+ /** Opening the socket, or the address was not usable once resolved. */
24
+ Open: "open",
25
+ /** The handshake, from the first packet until the shared secret exists. */
26
+ Handshake: "handshake",
27
+ /** After the handshake, waiting for the server to accept the client: a wrong password, a ban or an invalid nickname end here. */
28
+ Join: "join"
29
+ });
30
+ //#endregion
31
+ //#region src/hooks.ts
32
+ /**
33
+ * Report an error from a trace hook the way a throwing event listener's is
34
+ * reported: rethrown on its own, as an uncaught exception.
35
+ */
36
+ function reportHookError(error) {
37
+ queueMicrotask(() => {
38
+ throw error;
39
+ });
40
+ }
41
+ /**
42
+ * Call a trace hook, if there is one, and never let it touch what called it:
43
+ * a throw, or the rejection of a promise it returns, is reported as a
44
+ * throwing listener's is. The info is built only when there is a hook.
45
+ */
46
+ function callHook(hook, owner, info) {
47
+ if (hook === void 0) return;
48
+ try {
49
+ const result = hook.call(owner, info());
50
+ if (result !== null && typeof result === "object" && "then" in result) Promise.resolve(result).then(void 0, reportHookError);
51
+ } catch (error) {
52
+ reportHookError(error);
53
+ }
54
+ }
20
55
  //#endregion
21
56
  //#region src/errors.ts
22
57
  /** `connect()` was called on a client that is connecting or connected. */
23
58
  var AlreadyConnectedError = class extends TeamspeakError {
24
59
  /** {@inheritDoc TeamspeakError.kind} */
25
60
  static kind = "AlreadyConnectedError";
61
+ static {
62
+ Object.defineProperty(this.prototype, "name", {
63
+ value: "AlreadyConnectedError",
64
+ writable: true,
65
+ configurable: true,
66
+ enumerable: false
67
+ });
68
+ }
26
69
  constructor() {
27
70
  super("already connecting or connected");
28
- this.name = "AlreadyConnectedError";
29
71
  }
30
72
  };
31
73
  /**
@@ -39,11 +81,18 @@ var AlreadyConnectedError = class extends TeamspeakError {
39
81
  var InvalidAddressError = class extends TeamspeakError {
40
82
  /** {@inheritDoc TeamspeakError.kind} */
41
83
  static kind = "InvalidAddressError";
84
+ static {
85
+ Object.defineProperty(this.prototype, "name", {
86
+ value: "InvalidAddressError",
87
+ writable: true,
88
+ configurable: true,
89
+ enumerable: false
90
+ });
91
+ }
42
92
  /** The address as given. */
43
93
  address;
44
94
  constructor(address, reason) {
45
95
  super(`invalid address ${JSON.stringify(address)}: ${reason}`);
46
- this.name = "InvalidAddressError";
47
96
  this.address = address;
48
97
  }
49
98
  };
@@ -54,9 +103,16 @@ var InvalidAddressError = class extends TeamspeakError {
54
103
  var EAXTagMismatchError = class extends TeamspeakError {
55
104
  /** {@inheritDoc TeamspeakError.kind} */
56
105
  static kind = "EAXTagMismatchError";
106
+ static {
107
+ Object.defineProperty(this.prototype, "name", {
108
+ value: "EAXTagMismatchError",
109
+ writable: true,
110
+ configurable: true,
111
+ enumerable: false
112
+ });
113
+ }
57
114
  constructor() {
58
115
  super("EAX tag mismatch");
59
- this.name = "EAXTagMismatchError";
60
116
  }
61
117
  };
62
118
  /**
@@ -66,18 +122,32 @@ var EAXTagMismatchError = class extends TeamspeakError {
66
122
  var FakeSignatureMismatchError = class extends TeamspeakError {
67
123
  /** {@inheritDoc TeamspeakError.kind} */
68
124
  static kind = "FakeSignatureMismatchError";
125
+ static {
126
+ Object.defineProperty(this.prototype, "name", {
127
+ value: "FakeSignatureMismatchError",
128
+ writable: true,
129
+ configurable: true,
130
+ enumerable: false
131
+ });
132
+ }
69
133
  constructor() {
70
134
  super("fake signature mismatch");
71
- this.name = "FakeSignatureMismatchError";
72
135
  }
73
136
  };
74
137
  /** A file transfer connection failed: it could not be opened, or broke during the transfer. */
75
138
  var FileTransferError = class extends TeamspeakError {
76
139
  /** {@inheritDoc TeamspeakError.kind} */
77
140
  static kind = "FileTransferError";
141
+ static {
142
+ Object.defineProperty(this.prototype, "name", {
143
+ value: "FileTransferError",
144
+ writable: true,
145
+ configurable: true,
146
+ enumerable: false
147
+ });
148
+ }
78
149
  constructor(message, options) {
79
150
  super(message, options);
80
- this.name = "FileTransferError";
81
151
  }
82
152
  };
83
153
  /**
@@ -85,21 +155,35 @@ var FileTransferError = class extends TeamspeakError {
85
155
  * Init1 step, public key, license or proof. `connect()` rejects with it, the
86
156
  * underlying failure as its `cause`.
87
157
  */
88
- var CryptoInitError = class extends TeamspeakError {
158
+ var HandshakeError = class extends TeamspeakError {
89
159
  /** {@inheritDoc TeamspeakError.kind} */
90
- static kind = "CryptoInitError";
160
+ static kind = "HandshakeError";
161
+ static {
162
+ Object.defineProperty(this.prototype, "name", {
163
+ value: "HandshakeError",
164
+ writable: true,
165
+ configurable: true,
166
+ enumerable: false
167
+ });
168
+ }
91
169
  constructor(message, options) {
92
170
  super(message, options);
93
- this.name = "CryptoInitError";
94
171
  }
95
172
  };
96
173
  /** An identity string `parseIdentity()` cannot read. */
97
174
  var InvalidIdentityError = class extends TeamspeakError {
98
175
  /** {@inheritDoc TeamspeakError.kind} */
99
176
  static kind = "InvalidIdentityError";
177
+ static {
178
+ Object.defineProperty(this.prototype, "name", {
179
+ value: "InvalidIdentityError",
180
+ writable: true,
181
+ configurable: true,
182
+ enumerable: false
183
+ });
184
+ }
100
185
  constructor(message = "invalid identity format", options) {
101
186
  super(message, options);
102
- this.name = "InvalidIdentityError";
103
187
  }
104
188
  };
105
189
  /**
@@ -109,9 +193,16 @@ var InvalidIdentityError = class extends TeamspeakError {
109
193
  var ConnectionTimeoutError = class extends TeamspeakError {
110
194
  /** {@inheritDoc TeamspeakError.kind} */
111
195
  static kind = "ConnectionTimeoutError";
196
+ static {
197
+ Object.defineProperty(this.prototype, "name", {
198
+ value: "ConnectionTimeoutError",
199
+ writable: true,
200
+ configurable: true,
201
+ enumerable: false
202
+ });
203
+ }
112
204
  constructor() {
113
205
  super("connection timed out: the server stopped answering");
114
- this.name = "ConnectionTimeoutError";
115
206
  }
116
207
  };
117
208
  /**
@@ -122,6 +213,14 @@ var ConnectionTimeoutError = class extends TeamspeakError {
122
213
  var RemovedError = class extends TeamspeakError {
123
214
  /** {@inheritDoc TeamspeakError.kind} */
124
215
  static kind = "RemovedError";
216
+ static {
217
+ Object.defineProperty(this.prototype, "name", {
218
+ value: "RemovedError",
219
+ writable: true,
220
+ configurable: true,
221
+ enumerable: false
222
+ });
223
+ }
125
224
  /** Why: `Reason.KickServer`, `KickServerBan`, `ServerStop` or `ServerShutdown`. */
126
225
  reason;
127
226
  /** The reason the server or the invoker gave, if any. */
@@ -133,7 +232,6 @@ var RemovedError = class extends TeamspeakError {
133
232
  if (invokerName) message += ` by ${invokerName}`;
134
233
  if (reasonMessage) message += `: ${reasonMessage}`;
135
234
  super(message);
136
- this.name = "RemovedError";
137
235
  this.reason = reason;
138
236
  this.reasonMessage = reasonMessage;
139
237
  this.invokerName = invokerName;
@@ -149,6 +247,107 @@ function reasonName(reason) {
149
247
  }
150
248
  }
151
249
  //#endregion
250
+ //#region src/handshake/crypt-handshake.ts
251
+ var INIT_VERSION = 1566914096;
252
+ var INIT_STEP_LEN = 21;
253
+ /** The RSA challenge each connection attempt, by its Crypt, has answered. */
254
+ var answeredChallenges = /* @__PURE__ */ new WeakMap();
255
+ /**
256
+ * The RSA puzzle `crypt`'s attempt solved: the level the server demanded and
257
+ * the milliseconds spent on it. Level 0 and no time when the server sent none.
258
+ */
259
+ function puzzleOf(crypt) {
260
+ const answered = answeredChallenges.get(crypt);
261
+ return {
262
+ level: answered?.level ?? 0,
263
+ durationMs: answered?.durationMs ?? 0
264
+ };
265
+ }
266
+ /**
267
+ * Handle the TS3INIT1 handshake steps.
268
+ * Returns the response bytes to send, or null if nothing should be sent.
269
+ * Throws a HandshakeError for a step that cannot be answered.
270
+ */
271
+ function processInit1(crypt, data) {
272
+ if (data === null || data.length >= 1 && data[0] === 127) return buildInit1StartPacket();
273
+ if (data.length === 0) throw new HandshakeError("empty Init1 packet");
274
+ switch (data[0]) {
275
+ case 0: return buildInit1Step1Packet(data);
276
+ case 1: return buildInit1Step2Packet(data);
277
+ case 2: return buildInit1Step3Packet(data);
278
+ case 3: return buildInit1Step4Packet(crypt, data);
279
+ default: return null;
280
+ }
281
+ }
282
+ function buildInit1StartPacket() {
283
+ const sendData = /* @__PURE__ */ new Uint8Array(21);
284
+ const view = new DataView(sendData.buffer);
285
+ view.setUint32(0, INIT_VERSION, false);
286
+ sendData[4] = 0;
287
+ const nowSec = Math.floor(Date.now() / 1e3);
288
+ const clampedNow = Math.max(0, Math.min(nowSec, 4294967295));
289
+ view.setUint32(5, clampedNow, false);
290
+ const rng = randomBytes(4);
291
+ sendData.set(rng, 9);
292
+ return sendData;
293
+ }
294
+ function buildInit1Step1Packet(data) {
295
+ if (data.length !== INIT_STEP_LEN) return null;
296
+ const sendData = /* @__PURE__ */ new Uint8Array(21);
297
+ sendData[0] = 1;
298
+ const tsRand = data[9] | data[10] << 8 | data[11] << 16 | data[12] << 24;
299
+ new DataView(sendData.buffer).setUint32(17, tsRand >>> 0, false);
300
+ return sendData;
301
+ }
302
+ function buildInit1Step2Packet(data) {
303
+ if (data.length !== INIT_STEP_LEN) return null;
304
+ const sendData = /* @__PURE__ */ new Uint8Array(25);
305
+ new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
306
+ sendData[4] = 2;
307
+ sendData.set(data.slice(1, 21), 5);
308
+ return sendData;
309
+ }
310
+ function buildInit1Step3Packet(data) {
311
+ if (data.length !== 25) return null;
312
+ const sendData = /* @__PURE__ */ new Uint8Array(233);
313
+ sendData[0] = 3;
314
+ sendData[64] = 1;
315
+ sendData[128] = 1;
316
+ new DataView(sendData.buffer).setUint32(129, 1, false);
317
+ return sendData;
318
+ }
319
+ function buildInit1Step4Packet(crypt, data) {
320
+ if (data.length !== 233) return null;
321
+ const answered = answeredChallenges.get(crypt);
322
+ if (answered !== void 0) return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
323
+ const level = new DataView(data.buffer, data.byteOffset).getUint32(129, false);
324
+ const solveStarted = performance.now();
325
+ const y = crypt.solveRsaChallenge(data, 1, level);
326
+ const durationMs = performance.now() - solveStarted;
327
+ const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
328
+ const omegaB64 = crypt.identity.publicKey();
329
+ const cmd = buildCommandOrdered("clientinitiv", [
330
+ ["alpha", alphaB64],
331
+ ["omega", omegaB64],
332
+ ["ot", "1"],
333
+ ["ip", ""]
334
+ ]);
335
+ const cmdBytes = Buffer.from(cmd);
336
+ const sendData = new Uint8Array(301 + cmdBytes.length);
337
+ new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
338
+ sendData[4] = 4;
339
+ sendData.set(data.slice(1, 233), 5);
340
+ sendData.set(y.slice(0, 64), 237);
341
+ sendData.set(cmdBytes, 301);
342
+ answeredChallenges.set(crypt, {
343
+ challenge: Uint8Array.from(data),
344
+ answer: sendData,
345
+ level,
346
+ durationMs
347
+ });
348
+ return sendData;
349
+ }
350
+ //#endregion
152
351
  //#region src/abort.ts
153
352
  /** The reason an aborted signal gives, as an error. */
154
353
  function abortReason(signal) {
@@ -665,12 +864,12 @@ var Crypt = class {
665
864
  return this.#alpha;
666
865
  }
667
866
  solveRsaChallenge(data, offset, level) {
668
- if (!Number.isInteger(level) || level < 0 || level > 1e5) throw new CryptoInitError(`RSA challenge level ${level} out of range`);
867
+ if (!Number.isInteger(level) || level < 0 || level > 1e5) throw new HandshakeError(`RSA challenge level ${level} out of range`);
669
868
  const xBytes = data.slice(offset, offset + 64);
670
869
  const nBytes = data.slice(offset + 64, offset + 128);
671
870
  let y = bytesToBigInt(xBytes);
672
871
  const n = bytesToBigInt(nBytes);
673
- if (n === 0n) throw new CryptoInitError("RSA challenge modulus is zero");
872
+ if (n === 0n) throw new HandshakeError("RSA challenge modulus is zero");
674
873
  for (let i = 0; i < level; i++) y = y * y % n;
675
874
  return bigIntToBytes(y, 64);
676
875
  }
@@ -682,7 +881,7 @@ var Crypt = class {
682
881
  try {
683
882
  sharedSecret = this.#getSharedSecret(importPublicKey(omegaBytes));
684
883
  } catch (error) {
685
- throw new CryptoInitError("the server's public key is invalid", { cause: error });
884
+ throw new HandshakeError("the server's public key is invalid", { cause: error });
686
885
  }
687
886
  this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
688
887
  }
@@ -705,7 +904,7 @@ var Crypt = class {
705
904
  }
706
905
  getKeyNonce(fromServer, packetID, generationID, packetType, dummy) {
707
906
  if (dummy) return [Uint8Array.from(DUMMY_KEY), Uint8Array.from(DUMMY_NONCE)];
708
- if (!this.#cryptoInitComplete) throw new CryptoInitError("no session key before crypto init completes");
907
+ if (!this.#cryptoInitComplete) throw new HandshakeError("no session key before crypto init completes");
709
908
  const cacheKey = makeCacheKey(fromServer, packetType, generationID);
710
909
  let kn = this.#cachedKeys.get(cacheKey);
711
910
  if (kn === void 0) {
@@ -971,7 +1170,7 @@ var Resolver = class {
971
1170
  * address as given is the answer.
972
1171
  */
973
1172
  async resolve(inputAddr, options = {}) {
974
- if (!inputAddr) throw new Error("empty address");
1173
+ if (!inputAddr) throw new InvalidAddressError(inputAddr, "empty address");
975
1174
  const budget = AbortSignal.timeout(RESOLVE_BUDGET_MS);
976
1175
  const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget;
977
1176
  try {
@@ -1039,7 +1238,7 @@ var Resolver = class {
1039
1238
  try {
1040
1239
  const srvs = await abortable(resolveSrv(`_ts3._udp.${host}`), signal);
1041
1240
  if (!srvs || srvs.length === 0) return null;
1042
- return srvs.map((srv) => ({
1241
+ return orderSrvRecords(srvs).map((srv) => ({
1043
1242
  addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
1044
1243
  source: "SRV"
1045
1244
  }));
@@ -1051,7 +1250,7 @@ var Resolver = class {
1051
1250
  for (const domain of domains) try {
1052
1251
  const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
1053
1252
  if (!srvs || srvs.length === 0) continue;
1054
- for (const srv of srvs) {
1253
+ for (const srv of orderSrvRecords(srvs)) {
1055
1254
  const result = await queryTSDNS(joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)), queryHost, signal);
1056
1255
  if (result) return result;
1057
1256
  }
@@ -1064,6 +1263,48 @@ var Resolver = class {
1064
1263
  return (await Promise.all(domains.map((domain) => queryTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, signal)))).find((answer) => answer !== null) ?? null;
1065
1264
  }
1066
1265
  };
1266
+ /**
1267
+ * Put SRV records in the order RFC 2782 has a client try them: by priority,
1268
+ * lowest first, and within a priority by weighted random selection, so a
1269
+ * record of weight 2 is first twice as often as one of weight 1. Records of
1270
+ * weight 0 follow the others of their priority, in the order given. Node's
1271
+ * `resolveSrv` returns the records unsorted.
1272
+ *
1273
+ * @param random Returns a number in [0, 1); injected to make the order testable.
1274
+ * @internal
1275
+ */
1276
+ function orderSrvRecords(records, random = Math.random) {
1277
+ const byPriority = /* @__PURE__ */ new Map();
1278
+ for (const record of records) {
1279
+ const group = byPriority.get(record.priority);
1280
+ if (group) group.push(record);
1281
+ else byPriority.set(record.priority, [record]);
1282
+ }
1283
+ const ordered = [];
1284
+ for (const priority of [...byPriority.keys()].sort((a, b) => a - b)) {
1285
+ const group = byPriority.get(priority);
1286
+ const weighted = group.filter((r) => r.weight > 0);
1287
+ const unweighted = group.filter((r) => r.weight <= 0);
1288
+ let sum = weighted.reduce((total, r) => total + r.weight, 0);
1289
+ while (weighted.length > 0) {
1290
+ const target = random() * sum;
1291
+ let running = 0;
1292
+ let pick = weighted.length - 1;
1293
+ for (let i = 0; i < weighted.length; i++) {
1294
+ running += weighted[i].weight;
1295
+ if (running > target) {
1296
+ pick = i;
1297
+ break;
1298
+ }
1299
+ }
1300
+ const [chosen] = weighted.splice(pick, 1);
1301
+ ordered.push(chosen);
1302
+ sum -= chosen.weight;
1303
+ }
1304
+ ordered.push(...unweighted);
1305
+ }
1306
+ return ordered;
1307
+ }
1067
1308
  /** @internal Ask the nickname service for `nickname`'s address. */
1068
1309
  async function resolveNickname(nickname, signal) {
1069
1310
  try {
@@ -1406,92 +1647,6 @@ function parseQlzHeader(data) {
1406
1647
  };
1407
1648
  }
1408
1649
  //#endregion
1409
- //#region src/handshake/crypt-handshake.ts
1410
- var INIT_VERSION = 1566914096;
1411
- var INIT_STEP_LEN = 21;
1412
- /** The RSA challenge each connection attempt, by its Crypt, has answered. */
1413
- var answeredChallenges = /* @__PURE__ */ new WeakMap();
1414
- /**
1415
- * Handle the TS3INIT1 handshake steps.
1416
- * Returns the response bytes to send, or null if nothing should be sent.
1417
- * Throws a CryptoInitError for a step that cannot be answered.
1418
- */
1419
- function processInit1(crypt, data) {
1420
- if (data === null || data.length >= 1 && data[0] === 127) return buildInit1StartPacket();
1421
- if (data.length === 0) throw new CryptoInitError("empty Init1 packet");
1422
- switch (data[0]) {
1423
- case 0: return buildInit1Step1Packet(data);
1424
- case 1: return buildInit1Step2Packet(data);
1425
- case 2: return buildInit1Step3Packet(data);
1426
- case 3: return buildInit1Step4Packet(crypt, data);
1427
- default: return null;
1428
- }
1429
- }
1430
- function buildInit1StartPacket() {
1431
- const sendData = /* @__PURE__ */ new Uint8Array(21);
1432
- const view = new DataView(sendData.buffer);
1433
- view.setUint32(0, INIT_VERSION, false);
1434
- sendData[4] = 0;
1435
- const nowSec = Math.floor(Date.now() / 1e3);
1436
- const clampedNow = Math.max(0, Math.min(nowSec, 4294967295));
1437
- view.setUint32(5, clampedNow, false);
1438
- const rng = randomBytes(4);
1439
- sendData.set(rng, 9);
1440
- return sendData;
1441
- }
1442
- function buildInit1Step1Packet(data) {
1443
- if (data.length !== INIT_STEP_LEN) return null;
1444
- const sendData = /* @__PURE__ */ new Uint8Array(21);
1445
- sendData[0] = 1;
1446
- const tsRand = data[9] | data[10] << 8 | data[11] << 16 | data[12] << 24;
1447
- new DataView(sendData.buffer).setUint32(17, tsRand >>> 0, false);
1448
- return sendData;
1449
- }
1450
- function buildInit1Step2Packet(data) {
1451
- if (data.length !== INIT_STEP_LEN) return null;
1452
- const sendData = /* @__PURE__ */ new Uint8Array(25);
1453
- new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
1454
- sendData[4] = 2;
1455
- sendData.set(data.slice(1, 21), 5);
1456
- return sendData;
1457
- }
1458
- function buildInit1Step3Packet(data) {
1459
- if (data.length !== 25) return null;
1460
- const sendData = /* @__PURE__ */ new Uint8Array(233);
1461
- sendData[0] = 3;
1462
- sendData[64] = 1;
1463
- sendData[128] = 1;
1464
- new DataView(sendData.buffer).setUint32(129, 1, false);
1465
- return sendData;
1466
- }
1467
- function buildInit1Step4Packet(crypt, data) {
1468
- if (data.length !== 233) return null;
1469
- const answered = answeredChallenges.get(crypt);
1470
- if (answered !== void 0) return Buffer.from(answered.challenge).equals(Buffer.from(data)) ? answered.answer : null;
1471
- const level = new DataView(data.buffer, data.byteOffset).getUint32(129, false);
1472
- const y = crypt.solveRsaChallenge(data, 1, level);
1473
- const alphaB64 = Buffer.from(crypt.newAlpha()).toString("base64");
1474
- const omegaB64 = crypt.identity.publicKey();
1475
- const cmd = buildCommandOrdered("clientinitiv", [
1476
- ["alpha", alphaB64],
1477
- ["omega", omegaB64],
1478
- ["ot", "1"],
1479
- ["ip", ""]
1480
- ]);
1481
- const cmdBytes = Buffer.from(cmd);
1482
- const sendData = new Uint8Array(301 + cmdBytes.length);
1483
- new DataView(sendData.buffer).setUint32(0, INIT_VERSION, false);
1484
- sendData[4] = 4;
1485
- sendData.set(data.slice(1, 233), 5);
1486
- sendData.set(y.slice(0, 64), 237);
1487
- sendData.set(cmdBytes, 301);
1488
- answeredChallenges.set(crypt, {
1489
- challenge: Uint8Array.from(data),
1490
- answer: sendData
1491
- });
1492
- return sendData;
1493
- }
1494
- //#endregion
1495
1650
  //#region src/transport/handler.ts
1496
1651
  var RECEIVE_PACKET_WINDOW_SIZE = 1024;
1497
1652
  var PING_INTERVAL_MS = 5e3;
@@ -1535,6 +1690,8 @@ var PacketHandler = class {
1535
1690
  * ends the connection. Without an owner the error is logged.
1536
1691
  */
1537
1692
  onReceiveError = null;
1693
+ /** Called with the socket once it is open, before the handshake's first packet is sent. */
1694
+ onOpen = null;
1538
1695
  #crypt;
1539
1696
  #logger;
1540
1697
  #socketFactory;
@@ -1543,7 +1700,7 @@ var PacketHandler = class {
1543
1700
  #closed = false;
1544
1701
  #stopPing = null;
1545
1702
  #stopResend = null;
1546
- #lastMessageReceived = Date.now();
1703
+ #lastMessageReceived = performance.now();
1547
1704
  #packetCounter = /* @__PURE__ */ new Uint16Array(9);
1548
1705
  #generationCounter = /* @__PURE__ */ new Uint32Array(9);
1549
1706
  #recvWindowCommand = new GenerationWindow(65536, RECEIVE_PACKET_WINDOW_SIZE);
@@ -1623,6 +1780,7 @@ var PacketHandler = class {
1623
1780
  done = true;
1624
1781
  socket.off("error", onError);
1625
1782
  signal?.removeEventListener("abort", onAbort);
1783
+ this.onOpen?.(socket);
1626
1784
  this.start(socket);
1627
1785
  resolve();
1628
1786
  });
@@ -1639,7 +1797,7 @@ var PacketHandler = class {
1639
1797
  return;
1640
1798
  }
1641
1799
  this.#conn = conn;
1642
- this.#lastMessageReceived = Date.now();
1800
+ this.#lastMessageReceived = performance.now();
1643
1801
  conn.on("message", (msg) => {
1644
1802
  if (this.#closed) return;
1645
1803
  try {
@@ -1797,8 +1955,8 @@ var PacketHandler = class {
1797
1955
  if (dummy && pType === PacketType.Command) this.#dummyCommandIDs.add(pID);
1798
1956
  const rp = {
1799
1957
  packet: p,
1800
- firstSend: Date.now(),
1801
- lastSend: Date.now(),
1958
+ firstSend: performance.now(),
1959
+ lastSend: performance.now(),
1802
1960
  retryCount: 0,
1803
1961
  nextInterval: RESEND_BASE_INTERVAL_MS
1804
1962
  };
@@ -1811,7 +1969,7 @@ var PacketHandler = class {
1811
1969
  if (err) this.#logger.warn("udp send error", err);
1812
1970
  });
1813
1971
  } catch (error) {
1814
- const normalized = error instanceof Error ? error : new Error(String(error));
1972
+ const normalized = error instanceof Error ? error : new Error(String(error), { cause: error });
1815
1973
  this.#logger.error("udp send failed", normalized);
1816
1974
  this.#triggerClose(normalized);
1817
1975
  }
@@ -1832,12 +1990,12 @@ var PacketHandler = class {
1832
1990
  clientID: 0,
1833
1991
  generationID: this.#resolveGeneration(parsed.id, parsed.typeFlagged & 15),
1834
1992
  data: /* @__PURE__ */ new Uint8Array(0),
1835
- receivedAt: Date.now()
1993
+ receivedAt: performance.now()
1836
1994
  };
1837
1995
  const decrypted = this.#decryptPacketData(p, header, ciphertext, tag);
1838
1996
  if (decrypted === null) return;
1839
1997
  p.data = decrypted.plaintext;
1840
- this.#lastMessageReceived = Date.now();
1998
+ this.#lastMessageReceived = performance.now();
1841
1999
  if (packetType(p) === PacketType.Ping) {
1842
2000
  this.#sendPong(p.id, decrypted.dummyUsed);
1843
2001
  return;
@@ -2053,7 +2211,7 @@ var PacketHandler = class {
2053
2211
  }
2054
2212
  }
2055
2213
  #checkResends() {
2056
- const now = Date.now();
2214
+ const now = performance.now();
2057
2215
  if (now - this.#lastMessageReceived > PACKET_TIMEOUT_MS) {
2058
2216
  this.#logger.warn("idle timeout");
2059
2217
  this.#triggerClose(new ConnectionTimeoutError());
@@ -2103,7 +2261,7 @@ function applyProtocolFlags(pType, flags) {
2103
2261
  return flags;
2104
2262
  }
2105
2263
  function hasOldNewerPacket(queue, nextID) {
2106
- const now = Date.now();
2264
+ const now = performance.now();
2107
2265
  for (const [id, pkg] of queue) if ((id - nextID + 65536 & 65535) < 32768 && now - pkg.receivedAt > 5e3) return true;
2108
2266
  return false;
2109
2267
  }
@@ -2285,12 +2443,75 @@ function appendReturnCode(cmd, rc) {
2285
2443
  */
2286
2444
  /** How long dialing may take before it fails, unless a signal says otherwise. */
2287
2445
  var DIAL_TIMEOUT_MS = 1e4;
2446
+ /** How long a transfer may go without moving a byte, unless `idleTimeoutMs` says otherwise. */
2447
+ var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
2448
+ /** How many bytes move between two calls of the `progress` hook, at least. */
2449
+ var PROGRESS_STEP_BYTES = 65536;
2450
+ function newIdleGuard(options) {
2451
+ const limitMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
2452
+ if (!Number.isFinite(limitMs) || limitMs < 0) throw new RangeError("idleTimeoutMs must be a finite number of milliseconds, 0 or more");
2453
+ const controller = new AbortController();
2454
+ if (limitMs === 0) return {
2455
+ signal: controller.signal,
2456
+ limitMs,
2457
+ touch() {},
2458
+ stop() {}
2459
+ };
2460
+ const timer = setTimeout(() => controller.abort(new DOMException(`no data moved for ${limitMs} ms`, "TimeoutError")), limitMs);
2461
+ return {
2462
+ signal: controller.signal,
2463
+ limitMs,
2464
+ touch: () => timer.refresh(),
2465
+ stop: () => clearTimeout(timer)
2466
+ };
2467
+ }
2468
+ /** The error a transfer fails with when `idle` expired, else `error` as it is. */
2469
+ function idleOr(idle, error) {
2470
+ if (!idle.signal.aborted) return error;
2471
+ return new FileTransferError(`file transfer stalled: no data moved for ${idle.limitMs} ms`, { cause: idle.signal.reason });
2472
+ }
2473
+ function newTransferTrace(options) {
2474
+ return {
2475
+ trace: options.trace === void 0 ? void 0 : { ...options.trace },
2476
+ startedAt: performance.now(),
2477
+ bytes: 0
2478
+ };
2479
+ }
2480
+ function reportFailed(t, error, stage) {
2481
+ callHook(t.trace?.failed, t.trace, () => ({
2482
+ error,
2483
+ bytes: t.bytes,
2484
+ durationMs: performance.now() - t.startedAt,
2485
+ stage
2486
+ }));
2487
+ }
2288
2488
  /**
2289
2489
  * Open a connection to the file transfer port and send the transfer key.
2290
2490
  * The caller owns the returned stream and must end or destroy it.
2291
2491
  */
2292
2492
  async function dialFileTransfer(host, port, key, options = {}) {
2493
+ const t = newTransferTrace(options);
2494
+ const idle = newIdleGuard(options);
2495
+ try {
2496
+ return await dial(host, port, key, options, t, idle);
2497
+ } catch (error) {
2498
+ const failure = idleOr(idle, error);
2499
+ reportFailed(t, failure, "dial");
2500
+ throw failure;
2501
+ } finally {
2502
+ idle.stop();
2503
+ }
2504
+ }
2505
+ async function dial(host, port, key, options, t, idle) {
2293
2506
  const { signal, socketFactory } = options;
2507
+ const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
2508
+ const dialSignal = AbortSignal.any(signal ? [
2509
+ signal,
2510
+ timeout,
2511
+ idle.signal
2512
+ ] : [timeout, idle.signal]);
2513
+ const addr = joinHostPort(host, String(port));
2514
+ callHook(t.trace?.dialStart, t.trace, () => ({ addr }));
2294
2515
  signal?.throwIfAborted();
2295
2516
  const socket = socketFactory ? socketFactory(host, port) : connect({
2296
2517
  host,
@@ -2299,57 +2520,138 @@ async function dialFileTransfer(host, port, key, options = {}) {
2299
2520
  const swallow = () => {};
2300
2521
  socket.on("error", swallow);
2301
2522
  try {
2302
- await connected(socket, signal);
2303
- await new Promise((resolve, reject) => {
2523
+ await abortable(connected(socket), dialSignal);
2524
+ idle.touch();
2525
+ await abortable(new Promise((resolve, reject) => {
2304
2526
  socket.write(key, (err) => err ? reject(new FileTransferError(`failed to send transfer key: ${err.message}`, { cause: err })) : resolve());
2305
- });
2527
+ }), dialSignal);
2528
+ idle.touch();
2306
2529
  } catch (error) {
2307
2530
  socket.destroy();
2531
+ if (timeout.aborted && error === timeout.reason) throw new FileTransferError("file transfer connection timed out", { cause: error });
2308
2532
  throw error;
2309
2533
  } finally {
2310
2534
  socket.off("error", swallow);
2311
2535
  }
2536
+ callHook(t.trace?.dialDone, t.trace, () => {
2537
+ let localAddr = "";
2538
+ const local = socket;
2539
+ if (typeof local.localAddress === "string" && typeof local.localPort === "number") localAddr = joinHostPort(local.localAddress, String(local.localPort));
2540
+ return {
2541
+ addr,
2542
+ localAddr,
2543
+ durationMs: performance.now() - t.startedAt
2544
+ };
2545
+ });
2312
2546
  return socket;
2313
2547
  }
2314
2548
  /**
2549
+ * A pass-through that counts what moves and reports it to `progress`. It never
2550
+ * fails the pipeline: a hook's error is reported, not thrown.
2551
+ */
2552
+ function countingStream(t, total, idle) {
2553
+ let reported = 0;
2554
+ return new Transform({ transform(chunk, _encoding, callback) {
2555
+ t.bytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
2556
+ idle.touch();
2557
+ if (t.bytes - reported >= PROGRESS_STEP_BYTES) {
2558
+ reported = t.bytes;
2559
+ const bytes = t.bytes;
2560
+ callHook(t.trace?.progress, t.trace, () => ({
2561
+ bytes,
2562
+ total
2563
+ }));
2564
+ }
2565
+ callback(null, chunk);
2566
+ } });
2567
+ }
2568
+ /**
2569
+ * Passes exactly `expected` bytes. One more byte fails the transfer, and is
2570
+ * not passed on; a stream that ends before `expected` fails it too.
2571
+ */
2572
+ function exactSize(expected) {
2573
+ let received = 0;
2574
+ return new Transform({
2575
+ transform(chunk, _encoding, callback) {
2576
+ const room = expected - received;
2577
+ if (chunk.length > room) {
2578
+ this.push(chunk.subarray(0, room));
2579
+ received = expected;
2580
+ callback(new FileTransferError(`file transfer sent more than the announced ${expected} bytes`));
2581
+ return;
2582
+ }
2583
+ received += chunk.length;
2584
+ callback(null, chunk);
2585
+ },
2586
+ flush(callback) {
2587
+ callback(received === expected ? null : new FileTransferError(`file transfer ended after ${received} of the announced ${expected} bytes`));
2588
+ }
2589
+ });
2590
+ }
2591
+ /** Run a transfer's data phase, and report how the whole call ended. */
2592
+ async function transfer(host, port, key, total, options, move) {
2593
+ const t = newTransferTrace(options);
2594
+ const idle = newIdleGuard(options);
2595
+ try {
2596
+ let socket;
2597
+ try {
2598
+ socket = await dial(host, port, key, options, t, idle);
2599
+ } catch (error) {
2600
+ const failure = idleOr(idle, error);
2601
+ reportFailed(t, failure, "dial");
2602
+ throw failure;
2603
+ }
2604
+ try {
2605
+ const signal = options.signal ? AbortSignal.any([options.signal, idle.signal]) : idle.signal;
2606
+ await move(socket, countingStream(t, total, idle), { signal });
2607
+ } catch (error) {
2608
+ const failure = idleOr(idle, error);
2609
+ reportFailed(t, failure, "transfer");
2610
+ throw failure;
2611
+ }
2612
+ } finally {
2613
+ idle.stop();
2614
+ }
2615
+ const bytes = t.bytes;
2616
+ callHook(t.trace?.progress, t.trace, () => ({
2617
+ bytes,
2618
+ total
2619
+ }));
2620
+ callHook(t.trace?.done, t.trace, () => ({
2621
+ bytes,
2622
+ durationMs: performance.now() - t.startedAt
2623
+ }));
2624
+ }
2625
+ /**
2315
2626
  * Wait for a stream to be connected. A `node:net` socket says so with
2316
2627
  * `connect`; a stream from elsewhere that is already writable is taken as
2317
2628
  * connected.
2318
2629
  */
2319
- function connected(socket, signal) {
2630
+ function connected(socket) {
2320
2631
  const pending = socket.pending;
2321
2632
  if (pending === false || pending === void 0 && socket.writable) return Promise.resolve();
2322
2633
  return new Promise((resolve, reject) => {
2323
- const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
2324
2634
  const done = (error) => {
2325
2635
  socket.off("connect", onConnect);
2326
2636
  socket.off("ready", onConnect);
2327
2637
  socket.off("error", onError);
2328
- signal?.removeEventListener("abort", onAbort);
2329
- timeout.removeEventListener("abort", onTimeout);
2330
2638
  if (error === void 0) resolve();
2331
2639
  else reject(error);
2332
2640
  };
2333
2641
  const onConnect = () => done();
2334
2642
  const onError = (error) => done(new FileTransferError(`file transfer connection failed: ${error.message}`, { cause: error }));
2335
- const onAbort = () => done(signal.reason);
2336
- const onTimeout = () => done(new FileTransferError("file transfer connection timed out"));
2337
2643
  socket.once("connect", onConnect);
2338
2644
  socket.once("ready", onConnect);
2339
2645
  socket.once("error", onError);
2340
- signal?.addEventListener("abort", onAbort, { once: true });
2341
- timeout.addEventListener("abort", onTimeout, { once: true });
2342
2646
  });
2343
2647
  }
2344
2648
  /** Upload `data` over a negotiated transfer. Resolves once every byte is sent. */
2345
- async function uploadFileData(host, info, data, options = {}) {
2346
- const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
2347
- await pipeline(data, socket, options.signal ? { signal: options.signal } : {});
2649
+ function uploadFileData(host, info, data, options = {}) {
2650
+ return transfer(host, info.port, info.fileTransferKey, 0, options, (socket, counter, pipeOptions) => pipeline(data, counter, socket, pipeOptions));
2348
2651
  }
2349
2652
  /** Download a negotiated transfer into `dest`. Resolves once the file is written. */
2350
- async function downloadFileData(host, info, dest, options = {}) {
2351
- const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
2352
- await pipeline(socket, dest, options.signal ? { signal: options.signal } : {});
2653
+ function downloadFileData(host, info, dest, options = {}) {
2654
+ return transfer(host, info.port, info.fileTransferKey, Number(info.size), options, (socket, counter, pipeOptions) => pipeline(socket, exactSize(Number(info.size)), counter, dest, pipeOptions));
2353
2655
  }
2354
2656
  //#endregion
2355
2657
  //#region src/events.ts
@@ -2370,6 +2672,19 @@ function buildEventChain(middlewares, base) {
2370
2672
  //#endregion
2371
2673
  //#region src/capabilities.ts
2372
2674
  /**
2675
+ * What the connected server is, and what that means for the caller.
2676
+ *
2677
+ * TS3 and TS6 differ in ways that stay invisible until something breaks. The
2678
+ * clearest: the server hashes a client's public key with SHA-1 on TS3 and
2679
+ * SHA-256 on TS6, so a client that assumes either one computes a
2680
+ * `client_unique_identifier` the other server never sends.
2681
+ *
2682
+ * Everything below comes from the handshake — `initserver` carries
2683
+ * `virtualserver_version`, the one place the server names itself, along with
2684
+ * its name, icon, banner and welcome message — so reading it costs no extra
2685
+ * round trip.
2686
+ */
2687
+ /**
2373
2688
  * Read what a server says about itself in `initserver`, or a version string
2374
2689
  * alone, such as "6.0.0-beta13.1 [Build: 1]".
2375
2690
  */
@@ -2389,7 +2704,11 @@ function parseServerCapabilities(initserver) {
2389
2704
  name: params["virtualserver_name"] ?? "",
2390
2705
  iconID: parseIconID(params["virtualserver_icon_id"]),
2391
2706
  hostBannerImageURL: params["virtualserver_hostbanner_gfx_url"]?.trim() || void 0,
2392
- welcomeMessage: params["virtualserver_welcomemessage"] ?? ""
2707
+ welcomeMessage: params["virtualserver_welcomemessage"] ?? "",
2708
+ serverID: asServerID(parseUint64(params["virtualserver_id"] ?? "")),
2709
+ serverUID: params["virtualserver_unique_identifier"] ?? "",
2710
+ hostMessage: params["virtualserver_hostmessage"] ?? "",
2711
+ hostMessageMode: hostMessageMode(params["virtualserver_hostmessage_mode"])
2393
2712
  };
2394
2713
  }
2395
2714
  /**
@@ -2401,6 +2720,10 @@ function parseServerCapabilities(initserver) {
2401
2720
  function unknownServerCapabilities() {
2402
2721
  return parseServerCapabilities("");
2403
2722
  }
2723
+ function hostMessageMode(value) {
2724
+ const parsed = Number.parseInt(value ?? "", 10);
2725
+ return Number.isNaN(parsed) ? 0 : parsed;
2726
+ }
2404
2727
  //#endregion
2405
2728
  //#region src/handshake/license.ts
2406
2729
  var LICENSE_ROOT_KEY = new Uint8Array([
@@ -2589,7 +2912,7 @@ function deriveKeyFromBlock(block, parent) {
2589
2912
  * Mirrors Go's handshake.CryptoInit2.
2590
2913
  */
2591
2914
  function cryptoInit2(crypt, license, omega, proof, beta, privateKey) {
2592
- if (crypt.alpha.length === 0) throw new CryptoInitError("initivexpand2 arrived before clientinitiv was sent");
2915
+ if (crypt.alpha.length === 0) throw new HandshakeError("initivexpand2 arrived before clientinitiv was sent");
2593
2916
  const licenseBytes = Buffer.from(license, "base64");
2594
2917
  const omegaBytes = Buffer.from(omega, "base64");
2595
2918
  const proofBytes = Buffer.from(proof, "base64");
@@ -2598,14 +2921,14 @@ function cryptoInit2(crypt, license, omega, proof, beta, privateKey) {
2598
2921
  try {
2599
2922
  serverPubKey = importPublicKey(omegaBytes);
2600
2923
  } catch (error) {
2601
- throw new CryptoInitError("the server's public key is invalid", { cause: error });
2924
+ throw new HandshakeError("the server's public key is invalid", { cause: error });
2602
2925
  }
2603
- if (!verifySign(serverPubKey, licenseBytes, proofBytes)) throw new CryptoInitError("the server's license proof is not valid");
2926
+ if (!verifySign(serverPubKey, licenseBytes, proofBytes)) throw new HandshakeError("the server's license proof is not valid");
2604
2927
  let sharedSecret;
2605
2928
  try {
2606
2929
  sharedSecret = getSharedSecret2(parseLicenses(licenseBytes).deriveKey(), privateKey);
2607
2930
  } catch (error) {
2608
- throw new CryptoInitError("the server's license is invalid", { cause: error });
2931
+ throw new HandshakeError("the server's license is invalid", { cause: error });
2609
2932
  }
2610
2933
  crypt.setSharedSecret(crypt.alpha, betaBytes, sharedSecret);
2611
2934
  }
@@ -2618,6 +2941,7 @@ function handleHandshakeInitIV(client, params) {
2618
2941
  const omega = params["omega"] ?? "";
2619
2942
  client.handler.receivedFinalInitAck();
2620
2943
  client.crypt.initCrypto(alpha, beta, omega);
2944
+ client.markHandshakeDone(omega);
2621
2945
  client.logger.info("crypto initialized (P-256 path), sending clientinit");
2622
2946
  sendClientInit(client);
2623
2947
  }
@@ -2631,6 +2955,7 @@ function handleHandshakeExpand2(client, params) {
2631
2955
  const beta = params["beta"] ?? "";
2632
2956
  const privateKey = sendClientEkPacket(client, beta);
2633
2957
  cryptoInit2(client.crypt, license, omega, proof, beta, privateKey);
2958
+ client.markHandshakeDone(omega);
2634
2959
  sendClientInit(client);
2635
2960
  }
2636
2961
  /** Handle `initserver` — marks the client as connected. */
@@ -2643,7 +2968,7 @@ function handleInitServer(client, params) {
2643
2968
  }
2644
2969
  client.capabilities = parseServerCapabilities(params);
2645
2970
  client.logger.info("connected to server", { selfId: client.clid });
2646
- client.markConnected();
2971
+ client.markConnected(params);
2647
2972
  setImmediate(() => {
2648
2973
  const updateCmd = buildCommand("clientupdate", {
2649
2974
  client_input_muted: "0",
@@ -2807,7 +3132,7 @@ var CommandThrottle = class CommandThrottle {
2807
3132
  static TOKEN_RATE = 4;
2808
3133
  static TOKEN_MAX = 8;
2809
3134
  #tokens = 5;
2810
- #lastUpdate = Date.now();
3135
+ #lastUpdate = performance.now();
2811
3136
  #waiters = [];
2812
3137
  #timer;
2813
3138
  /**
@@ -2839,7 +3164,7 @@ var CommandThrottle = class CommandThrottle {
2839
3164
  });
2840
3165
  }
2841
3166
  #refill() {
2842
- const now = Date.now();
3167
+ const now = performance.now();
2843
3168
  const elapsed = (now - this.#lastUpdate) / 1e3;
2844
3169
  this.#tokens = Math.min(this.#tokens + elapsed * CommandThrottle.TOKEN_RATE, CommandThrottle.TOKEN_MAX);
2845
3170
  this.#lastUpdate = now;
@@ -3013,6 +3338,13 @@ var DEFAULT_COMMAND_TIMEOUT_MS = 1e4;
3013
3338
  var DISCONNECT_TIMEOUT_MS = 1e3;
3014
3339
  /** How long each reconnection attempt may take. */
3015
3340
  var RECONNECT_ATTEMPT_TIMEOUT_MS = 3e4;
3341
+ /** The `initserver` parameters a trace never sees, because they could carry a secret. */
3342
+ var SECRET_INITSERVER_PARAMS = /* @__PURE__ */ new Set([
3343
+ "client_server_password",
3344
+ "client_default_channel_password",
3345
+ "client_default_token",
3346
+ "client_key_offset"
3347
+ ]);
3016
3348
  function newConnectAttempt() {
3017
3349
  let resolve;
3018
3350
  let reject;
@@ -3026,6 +3358,8 @@ function newConnectAttempt() {
3026
3358
  promise,
3027
3359
  signal: controller.signal,
3028
3360
  settled: false,
3361
+ trace: void 0,
3362
+ onFail: void 0,
3029
3363
  resolve() {
3030
3364
  if (attempt.settled) return;
3031
3365
  attempt.settled = true;
@@ -3034,12 +3368,14 @@ function newConnectAttempt() {
3034
3368
  reject(error) {
3035
3369
  if (attempt.settled) return;
3036
3370
  attempt.settled = true;
3371
+ attempt.onFail?.(error);
3037
3372
  controller.abort(error);
3038
3373
  reject(error);
3039
3374
  },
3040
3375
  rejectAfter(error, until) {
3041
3376
  if (attempt.settled) return;
3042
3377
  attempt.settled = true;
3378
+ attempt.onFail?.(error);
3043
3379
  controller.abort(error);
3044
3380
  until.finally(() => reject(error));
3045
3381
  }
@@ -3086,6 +3422,10 @@ var Client = class {
3086
3422
  #clientInitOptions;
3087
3423
  #socketFactory;
3088
3424
  #fileTransferSocketFactory;
3425
+ /** A copy of the caller's trace, so changing theirs later changes nothing. */
3426
+ #trace;
3427
+ /** The session in progress, for its `sessionEnd`; cleared when that has been reported. */
3428
+ #session;
3089
3429
  /** Undefined when the client does not reconnect. */
3090
3430
  #reconnectOptions;
3091
3431
  /**
@@ -3106,6 +3446,8 @@ var Client = class {
3106
3446
  * the new one instead.
3107
3447
  */
3108
3448
  #leaving;
3449
+ /** Ends a disconnect()'s wait for its goodbye to be answered, while it waits. */
3450
+ #goodbyeAnswered;
3109
3451
  /** How many times disconnect() was called, so a waiting connect() sees one. */
3110
3452
  #disconnects = 0;
3111
3453
  /**
@@ -3141,6 +3483,7 @@ var Client = class {
3141
3483
  this.#socketFactory = options.socketFactory;
3142
3484
  this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
3143
3485
  this.#reconnectOptions = options.reconnect;
3486
+ this.#trace = options.trace === void 0 ? void 0 : { ...options.trace };
3144
3487
  this.#crypt = new Crypt(identity);
3145
3488
  this.#handler = this.#newHandler();
3146
3489
  const self = this;
@@ -3175,7 +3518,8 @@ var Client = class {
3175
3518
  set capabilities(value) {
3176
3519
  self.#capabilities = value;
3177
3520
  },
3178
- markConnected: () => this.#markConnected(),
3521
+ markConnected: (initServer) => this.#markConnected(initServer),
3522
+ markHandshakeDone: (serverPublicKey) => this.#markHandshakeDone(serverPublicKey),
3179
3523
  execNoWait: (command) => this.#execNoWait(command, void 0, true),
3180
3524
  handlePacket: (packet) => this.#handlePacket(packet)
3181
3525
  };
@@ -3214,6 +3558,9 @@ var Client = class {
3214
3558
  * after `await client.connect()` still hears it.
3215
3559
  */
3216
3560
  async connect(options = {}) {
3561
+ return this.#connect(options, 1, false);
3562
+ }
3563
+ async #connect(options, attemptNumber, reconnect) {
3217
3564
  const { signal } = options;
3218
3565
  signal?.throwIfAborted();
3219
3566
  const disconnects = this.#disconnects;
@@ -3226,14 +3573,61 @@ var Client = class {
3226
3573
  this.#resetForConnect();
3227
3574
  this.#status = ClientStatus.Connecting;
3228
3575
  const attempt = this.#attempt;
3576
+ const trace = {
3577
+ attempt: attemptNumber,
3578
+ reconnect,
3579
+ startedAt: performance.now(),
3580
+ connectStartedAt: 0,
3581
+ phase: ConnectPhase.Open,
3582
+ target: "",
3583
+ sessionStarted: false,
3584
+ welcomeDone: false,
3585
+ channels: 0
3586
+ };
3587
+ attempt.trace = trace;
3588
+ attempt.onFail = (error) => this.#callHook(this.#trace?.attemptFailed, () => ({
3589
+ attempt: trace.attempt,
3590
+ reconnect: trace.reconnect,
3591
+ phase: trace.phase,
3592
+ error,
3593
+ target: trace.target,
3594
+ durationMs: performance.now() - trace.startedAt
3595
+ }));
3229
3596
  const onAbort = () => this.#failConnection(abortReason(signal));
3230
3597
  signal?.addEventListener("abort", onAbort, { once: true });
3231
3598
  try {
3232
3599
  try {
3233
- const targetAddr = await this.#resolveAddr(attempt.signal);
3600
+ this.#callHook(this.#trace?.resolveStart, () => ({
3601
+ attempt: trace.attempt,
3602
+ reconnect: trace.reconnect,
3603
+ addr: this.#addr
3604
+ }));
3605
+ const resolveStarted = performance.now();
3606
+ const resolution = await this.#resolveAddr(attempt.signal);
3607
+ const targetAddr = resolution.target;
3608
+ trace.target = targetAddr;
3609
+ if (!attempt.settled) this.#callHook(this.#trace?.resolveDone, () => ({
3610
+ attempt: trace.attempt,
3611
+ reconnect: trace.reconnect,
3612
+ addr: this.#addr,
3613
+ target: targetAddr,
3614
+ source: resolution.source,
3615
+ candidates: resolution.candidates,
3616
+ error: resolution.error,
3617
+ durationMs: performance.now() - resolveStarted
3618
+ }));
3234
3619
  if (!attempt.settled) {
3235
3620
  this.#logger.info("connecting to server", { address: targetAddr });
3621
+ trace.connectStartedAt = performance.now();
3622
+ this.#callHook(this.#trace?.connectStart, () => ({
3623
+ attempt: trace.attempt,
3624
+ reconnect: trace.reconnect,
3625
+ target: targetAddr
3626
+ }));
3627
+ }
3628
+ if (!attempt.settled) {
3236
3629
  await this.#handler.connect(targetAddr, attempt.signal);
3630
+ if (trace.phase === ConnectPhase.Open) trace.phase = ConnectPhase.Handshake;
3237
3631
  }
3238
3632
  } catch (error) {
3239
3633
  this.#failConnection(error);
@@ -3275,14 +3669,26 @@ var Client = class {
3275
3669
  }
3276
3670
  async #leave(wasConnected, signal) {
3277
3671
  this.#logger.info("disconnecting from server");
3278
- if (wasConnected) try {
3279
- await this.#exec("clientdisconnect reasonmsg=Shutdown", {
3672
+ if (wasConnected) {
3673
+ this.#callHook(this.#trace?.disconnectStart, () => ({}));
3674
+ const goodbyeStarted = performance.now();
3675
+ const left = new Promise((resolve) => {
3676
+ this.#goodbyeAnswered = () => resolve(true);
3677
+ });
3678
+ const answered = this.#exec("clientdisconnect reasonmsg=Shutdown", {
3280
3679
  signal,
3281
3680
  timeout: DISCONNECT_TIMEOUT_MS
3282
- }, true);
3283
- } catch {}
3681
+ }, true).then(() => true, () => false);
3682
+ const acknowledged = await Promise.race([answered, left]);
3683
+ this.#goodbyeAnswered = void 0;
3684
+ this.#callHook(this.#trace?.disconnectDone, () => ({
3685
+ acknowledged,
3686
+ durationMs: performance.now() - goodbyeStarted
3687
+ }));
3688
+ }
3284
3689
  this.#handler.close();
3285
3690
  this.#cmdTrack.reset(new NotConnectedError());
3691
+ this.#endSession(void 0);
3286
3692
  await this.#announceDisconnected(void 0);
3287
3693
  }
3288
3694
  /** `await using client = …` disconnects when the scope ends. */
@@ -3424,67 +3830,95 @@ var Client = class {
3424
3830
  * ```
3425
3831
  *
3426
3832
  * Events that arrive while the loop body runs are buffered, so none is
3427
- * missed — except voice, of which at most 256 frames are buffered; the rest
3428
- * are dropped, as for a listener that falls behind. Iteration ends when
3429
- * `signal` aborts, when the loop exits early (`break`, `return`, a throw),
3430
- * and — for every event but `disconnected` — when the client disconnects.
3431
- * An abort stops listening at once and discards whatever is buffered.
3833
+ * missed. The buffer is unbounded: an iterator that is not consumed as fast
3834
+ * as events arrive holds every one of them in memory until it is read, so
3835
+ * stop iterating (or abort `signal`) when you stop reading. Voice is the
3836
+ * exception: at most 256 `voiceData` frames are buffered, and the rest are
3837
+ * dropped, as for a listener that falls behind.
3838
+ *
3839
+ * Iteration ends when the loop exits early (`break`, `return`, a throw), and
3840
+ * — for every event but `disconnected` — when the client disconnects. When
3841
+ * `signal` aborts, listening stops at once, whatever is buffered is
3842
+ * discarded, and the pending and every later `next()` reject with the
3843
+ * signal's reason, as `events.on` in Node does.
3844
+ *
3845
+ * `next()` may be called again before an earlier call has settled; the calls
3846
+ * are answered in order.
3432
3847
  */
3433
3848
  events(event, options = {}) {
3434
3849
  const { signal } = options;
3435
3850
  const buffered = [];
3436
- let wake;
3851
+ const waiting = [];
3437
3852
  let ended = false;
3853
+ let aborted = false;
3854
+ const finishWaiting = () => {
3855
+ for (const waiter of waiting.splice(0)) waiter.resolve({
3856
+ value: void 0,
3857
+ done: true
3858
+ });
3859
+ };
3438
3860
  const stopListening = this.on(event, ((payload) => {
3439
3861
  if (ended) return;
3862
+ const waiter = waiting.shift();
3863
+ if (waiter) {
3864
+ waiter.resolve({
3865
+ value: payload,
3866
+ done: false
3867
+ });
3868
+ return;
3869
+ }
3440
3870
  if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
3441
3871
  this.#dropVoice();
3442
3872
  return;
3443
3873
  }
3444
3874
  buffered.push(payload);
3445
- wake?.();
3446
3875
  }));
3447
3876
  const stopOnDisconnect = event === "disconnected" ? void 0 : this.on("disconnected", () => {
3448
- ended = true;
3449
- stopListening();
3450
- wake?.();
3877
+ cleanup();
3878
+ finishWaiting();
3451
3879
  });
3452
3880
  const cleanup = () => {
3453
3881
  ended = true;
3454
3882
  stopListening();
3455
3883
  stopOnDisconnect?.();
3456
3884
  signal?.removeEventListener("abort", abort);
3457
- wake?.();
3458
3885
  };
3459
3886
  const abort = () => {
3887
+ aborted = true;
3460
3888
  buffered.length = 0;
3461
3889
  cleanup();
3890
+ const reason = abortReason(signal);
3891
+ for (const waiter of waiting.splice(0)) waiter.reject(reason);
3462
3892
  };
3463
3893
  if (signal?.aborted) abort();
3464
3894
  else signal?.addEventListener("abort", abort, { once: true });
3465
3895
  const iterator = {
3466
- async next() {
3467
- while (buffered.length === 0 && !ended) {
3468
- await new Promise((resolve) => wake = resolve);
3469
- wake = void 0;
3470
- }
3471
- if (buffered.length > 0) return {
3896
+ next() {
3897
+ if (aborted) return Promise.reject(abortReason(signal));
3898
+ if (buffered.length > 0) return Promise.resolve({
3472
3899
  value: buffered.shift(),
3473
3900
  done: false
3474
- };
3475
- cleanup();
3476
- return {
3477
- value: void 0,
3478
- done: true
3479
- };
3901
+ });
3902
+ if (ended) {
3903
+ cleanup();
3904
+ return Promise.resolve({
3905
+ value: void 0,
3906
+ done: true
3907
+ });
3908
+ }
3909
+ return new Promise((resolve, reject) => waiting.push({
3910
+ resolve,
3911
+ reject
3912
+ }));
3480
3913
  },
3481
- async return() {
3914
+ return() {
3482
3915
  buffered.length = 0;
3483
3916
  cleanup();
3484
- return {
3917
+ finishWaiting();
3918
+ return Promise.resolve({
3485
3919
  value: void 0,
3486
3920
  done: true
3487
- };
3921
+ });
3488
3922
  },
3489
3923
  [Symbol.asyncIterator]() {
3490
3924
  return iterator;
@@ -3555,12 +3989,108 @@ var Client = class {
3555
3989
  socketFactory: this.#fileTransferSocketFactory
3556
3990
  });
3557
3991
  }
3558
- #markConnected() {
3992
+ #markConnected(initServer = {}) {
3559
3993
  if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
3560
3994
  this.#status = ClientStatus.Connected;
3561
3995
  this.#attempt.resolve();
3996
+ const trace = this.#attempt.trace;
3997
+ if (trace !== void 0) {
3998
+ trace.sessionStarted = true;
3999
+ this.#session = {
4000
+ clientID: asClientID(this.#clid),
4001
+ startedAt: performance.now(),
4002
+ attempt: trace.attempt,
4003
+ reconnect: trace.reconnect
4004
+ };
4005
+ this.#callHook(this.#trace?.sessionStart, () => {
4006
+ const safe = {};
4007
+ for (const [key, value] of Object.entries(initServer)) if (!SECRET_INITSERVER_PARAMS.has(key)) safe[key] = value;
4008
+ const talkPower = Number.parseInt(initServer["client_talk_power"] ?? "", 10);
4009
+ return {
4010
+ attempt: trace.attempt,
4011
+ reconnect: trace.reconnect,
4012
+ clientID: asClientID(this.#clid),
4013
+ nickname: initServer["acn"] ?? this.#nickname,
4014
+ talkPower: Number.isNaN(talkPower) ? 0 : talkPower,
4015
+ initServer: Object.freeze(safe),
4016
+ capabilities: this.#capabilities,
4017
+ durationMs: performance.now() - trace.connectStartedAt
4018
+ };
4019
+ });
4020
+ }
3562
4021
  this.#emit("connected", void 0);
3563
4022
  }
4023
+ /** The socket is open: the attempt's handshake begins. */
4024
+ #markConnectDone(socket) {
4025
+ const trace = this.#attempt.trace;
4026
+ if (trace === void 0 || this.#attempt.settled || trace.phase !== ConnectPhase.Open) return;
4027
+ trace.phase = ConnectPhase.Handshake;
4028
+ this.#callHook(this.#trace?.connectDone, () => {
4029
+ let localAddr = "";
4030
+ try {
4031
+ const local = socket.address?.();
4032
+ if (local !== void 0) localAddr = joinHostPort(local.address, String(local.port));
4033
+ } catch {}
4034
+ return {
4035
+ attempt: trace.attempt,
4036
+ reconnect: trace.reconnect,
4037
+ target: trace.target,
4038
+ localAddr,
4039
+ durationMs: performance.now() - trace.connectStartedAt
4040
+ };
4041
+ });
4042
+ }
4043
+ #markHandshakeDone(serverPublicKey = "") {
4044
+ const trace = this.#attempt.trace;
4045
+ if (trace === void 0 || this.#attempt.settled || trace.phase !== ConnectPhase.Handshake) return;
4046
+ trace.phase = ConnectPhase.Join;
4047
+ this.#callHook(this.#trace?.handshakeDone, () => {
4048
+ const puzzle = puzzleOf(this.#crypt);
4049
+ return {
4050
+ attempt: trace.attempt,
4051
+ reconnect: trace.reconnect,
4052
+ durationMs: performance.now() - trace.connectStartedAt,
4053
+ puzzleLevel: puzzle.level,
4054
+ puzzleDurationMs: puzzle.durationMs,
4055
+ resends: this.#handler.resends,
4056
+ serverPublicKey
4057
+ };
4058
+ });
4059
+ }
4060
+ #markWelcomeDone() {
4061
+ const trace = this.#attempt.trace;
4062
+ if (trace === void 0 || !trace.sessionStarted || trace.welcomeDone) return;
4063
+ if (this.#status !== ClientStatus.Connected) return;
4064
+ trace.welcomeDone = true;
4065
+ this.#callHook(this.#trace?.welcomeDone, () => ({
4066
+ attempt: trace.attempt,
4067
+ reconnect: trace.reconnect,
4068
+ channelID: this.channelID,
4069
+ channels: trace.channels,
4070
+ clients: this.#clients.size,
4071
+ durationMs: performance.now() - trace.connectStartedAt
4072
+ }));
4073
+ }
4074
+ /** Report the end of the session in progress, once. */
4075
+ #endSession(error) {
4076
+ const session = this.#session;
4077
+ if (session === void 0) return;
4078
+ this.#session = void 0;
4079
+ this.#callHook(this.#trace?.sessionEnd, () => ({
4080
+ clientID: session.clientID,
4081
+ error,
4082
+ durationMs: performance.now() - session.startedAt,
4083
+ attempt: session.attempt,
4084
+ reconnect: session.reconnect
4085
+ }));
4086
+ }
4087
+ /**
4088
+ * Call a trace hook, if there is one, and never let it touch the
4089
+ * connection: see callHook. The info is built only when there is a hook.
4090
+ */
4091
+ #callHook(hook, info) {
4092
+ callHook(hook, this.#trace, info);
4093
+ }
3564
4094
  #newHandler() {
3565
4095
  const handler = new PacketHandler(this.#crypt, this.#logger, this.#socketFactory);
3566
4096
  handler.onPacket = (p) => {
@@ -3569,6 +4099,9 @@ var Client = class {
3569
4099
  handler.onClosed = (err) => {
3570
4100
  if (handler === this.#handler) this.#handleConnectionClosed(err);
3571
4101
  };
4102
+ handler.onOpen = (socket) => {
4103
+ if (handler === this.#handler) this.#markConnectDone(socket);
4104
+ };
3572
4105
  handler.onReceiveError = (error) => {
3573
4106
  if (handler === this.#handler) this.#handleReceiveError(error);
3574
4107
  };
@@ -3591,23 +4124,36 @@ var Client = class {
3591
4124
  const timer = setTimeout(() => budget.abort(new TeamspeakError("address resolution took too long")), RESOLVE_BUDGET_MS);
3592
4125
  const lookup = AbortSignal.any([signal, budget.signal]);
3593
4126
  try {
3594
- return (await abortable(this.#resolver.resolve(this.#addr, { signal: lookup }), lookup))[0]?.addr ?? fallback;
4127
+ const resolved = await abortable(this.#resolver.resolve(this.#addr, { signal: lookup }), lookup);
4128
+ const first = resolved[0];
4129
+ return {
4130
+ target: first?.addr ?? fallback,
4131
+ source: first?.source ?? "",
4132
+ candidates: resolved,
4133
+ error: void 0
4134
+ };
3595
4135
  } catch (error) {
3596
4136
  signal.throwIfAborted();
3597
- this.#logger.warn("address resolution failed, falling back to direct", { error: String(budget.signal.aborted ? budget.signal.reason : error) });
3598
- return fallback;
4137
+ const reason = budget.signal.aborted ? budget.signal.reason : error;
4138
+ this.#logger.warn("address resolution failed, falling back to direct", { error: String(reason) });
4139
+ return {
4140
+ target: fallback,
4141
+ source: "",
4142
+ candidates: [],
4143
+ error: reason
4144
+ };
3599
4145
  } finally {
3600
4146
  clearTimeout(timer);
3601
4147
  }
3602
4148
  }
3603
4149
  /**
3604
4150
  * A datagram from the server could not be handled. During the handshake that
3605
- * is the connection failing, reported as a CryptoInitError with the cause;
4151
+ * is the connection failing, reported as a HandshakeError with the cause;
3606
4152
  * once connected, one bad packet is not worth the session, so it is dropped.
3607
4153
  */
3608
4154
  #handleReceiveError(error) {
3609
4155
  if (this.#status !== ClientStatus.Connected) {
3610
- this.#failConnection(error instanceof CryptoInitError ? error : new CryptoInitError("the handshake failed on a malformed packet", { cause: error }));
4156
+ this.#failConnection(error instanceof HandshakeError ? error : new HandshakeError("the handshake failed on a malformed packet", { cause: error }));
3611
4157
  return;
3612
4158
  }
3613
4159
  this.#logger.debug("dropped a malformed packet", { error: String(error) });
@@ -3674,8 +4220,14 @@ var Client = class {
3674
4220
  }
3675
4221
  if (cmd.name === "channellistfinished") {
3676
4222
  this.#cmdTrack.signalWelcomeComplete();
4223
+ this.#markWelcomeDone();
3677
4224
  return;
3678
4225
  }
4226
+ if (cmd.name === "channellist") {
4227
+ const trace = this.#attempt.trace;
4228
+ if (trace !== void 0 && !trace.welcomeDone) trace.channels++;
4229
+ }
4230
+ if (cmd.name === "notifyclientleftview" && this.#goodbyeAnswered !== void 0 && cmd.params["clid"] === String(this.#clid)) this.#goodbyeAnswered();
3679
4231
  if (cmd.name.startsWith("notify")) {
3680
4232
  if (this.#cmdTrack.expects(cmd.name)) {
3681
4233
  this.#cmdTrack.buffer(cmd.params, cmd.name);
@@ -3786,7 +4338,7 @@ var Client = class {
3786
4338
  */
3787
4339
  #dropVoice() {
3788
4340
  this.#voiceDropped++;
3789
- const now = Date.now();
4341
+ const now = performance.now();
3790
4342
  if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
3791
4343
  this.#voiceDropWarnedAt = now;
3792
4344
  this.#logger.warn("voice dropped: listeners are not keeping up", { dropped: this.#voiceDropped });
@@ -3860,6 +4412,7 @@ var Client = class {
3860
4412
  this.#status = ClientStatus.Disconnected;
3861
4413
  const cause = err ?? new TeamspeakError("connection closed");
3862
4414
  this.#lastDrop = cause;
4415
+ this.#endSession(cause);
3863
4416
  this.#announceDisconnected(cause);
3864
4417
  this.#scheduleReconnect(cause).catch((error) => {
3865
4418
  this.#logger.error("reconnecting failed", { error: String(error) });
@@ -3882,14 +4435,20 @@ var Client = class {
3882
4435
  this.#reconnecting = true;
3883
4436
  const stop = new AbortController();
3884
4437
  this.#stopReconnecting = stop;
4438
+ let made = 0;
4439
+ let stoppedBy;
3885
4440
  try {
3886
4441
  for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
3887
- if (this.#stopped) return;
4442
+ if (this.#stopped) {
4443
+ stoppedBy = { cause: void 0 };
4444
+ return;
4445
+ }
3888
4446
  let retry;
3889
4447
  try {
3890
4448
  retry = shouldRetry(err, attempt);
3891
4449
  } catch (error) {
3892
4450
  this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
4451
+ stoppedBy = { cause: err };
3893
4452
  return;
3894
4453
  }
3895
4454
  if (!retry) {
@@ -3897,6 +4456,7 @@ var Client = class {
3897
4456
  attempt,
3898
4457
  reason: err.message
3899
4458
  });
4459
+ stoppedBy = { cause: err };
3900
4460
  return;
3901
4461
  }
3902
4462
  const delay = backoffDelay(policy, attempt);
@@ -3904,14 +4464,23 @@ var Client = class {
3904
4464
  attempt,
3905
4465
  delayMs: delay
3906
4466
  });
4467
+ this.#callHook(this.#trace?.reconnectScheduled, () => ({
4468
+ attempt,
4469
+ delayMs: delay,
4470
+ cause: err
4471
+ }));
3907
4472
  await sleep(delay, stop.signal);
3908
- if (this.#stopped) return;
4473
+ if (this.#stopped) {
4474
+ stoppedBy = { cause: void 0 };
4475
+ return;
4476
+ }
3909
4477
  if (this.#status !== ClientStatus.Disconnected) {
3910
4478
  this.#logger.info("reconnect loop ended: connected by other means", { attempt });
3911
4479
  return;
3912
4480
  }
3913
4481
  try {
3914
- await this.connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) });
4482
+ made++;
4483
+ await this.#connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) }, attempt, true);
3915
4484
  if (this.status === ClientStatus.Connected) {
3916
4485
  this.#logger.info("reconnected", { attempt });
3917
4486
  return;
@@ -3919,11 +4488,19 @@ var Client = class {
3919
4488
  err = this.#lastDrop ?? new TeamspeakError("connection closed");
3920
4489
  } catch (retryErr) {
3921
4490
  if (retryErr instanceof AlreadyConnectedError) return;
3922
- err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr));
4491
+ err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr), { cause: retryErr });
3923
4492
  }
3924
4493
  }
3925
4494
  this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
4495
+ stoppedBy = { cause: err };
3926
4496
  } finally {
4497
+ if (stoppedBy !== void 0) {
4498
+ const { cause } = stoppedBy;
4499
+ this.#callHook(this.#trace?.reconnectStopped, () => ({
4500
+ attempts: made,
4501
+ cause
4502
+ }));
4503
+ }
3927
4504
  this.#reconnecting = false;
3928
4505
  if (this.#stopReconnecting === stop) this.#stopReconnecting = void 0;
3929
4506
  }
@@ -3944,7 +4521,7 @@ var Client = class {
3944
4521
  if (this.#status === ClientStatus.Disconnected) return;
3945
4522
  this.#status = ClientStatus.Disconnected;
3946
4523
  this.#handler.close();
3947
- this.#announceDisconnected(error instanceof Error ? error : new TeamspeakError(String(error)));
4524
+ this.#announceDisconnected(error instanceof Error ? error : new TeamspeakError(String(error), { cause: error }));
3948
4525
  }
3949
4526
  };
3950
4527
  function kickedEvent(event) {
@@ -3991,6 +4568,6 @@ async function unsubscribeAllChannels(client, options) {
3991
4568
  await client.send({ command: "channelunsubscribeall" }, options);
3992
4569
  }
3993
4570
  //#endregion
3994
- export { AlreadyConnectedError, Client, ClientStatus, ConnectionTimeoutError, CryptoInitError, FileTransferError, Identity, InvalidAddressError, InvalidIdentityError, RemovedError, backoffDelay, commandChannel, defaultBackoffPolicy, defaultShouldRetry, dialFileTransfer, downloadFileData, generateIdentity, generateIdentitySync, getChannelDescription, packetChannel, parseIdentity, subscribeAllChannels, subscribeChannel, uidFromPublicKey, unknownServerCapabilities, unsubscribeAllChannels, unsubscribeChannel, uploadFileData };
4571
+ export { AlreadyConnectedError, Client, ClientStatus, ConnectPhase, ConnectionTimeoutError, FileTransferError, HandshakeError, Identity, InvalidAddressError, InvalidIdentityError, RemovedError, backoffDelay, commandChannel, defaultBackoffPolicy, defaultShouldRetry, dialFileTransfer, downloadFileData, generateIdentity, generateIdentitySync, getChannelDescription, packetChannel, parseIdentity, subscribeAllChannels, subscribeChannel, uidFromPublicKey, unknownServerCapabilities, unsubscribeAllChannels, unsubscribeChannel, uploadFileData };
3995
4572
 
3996
4573
  //# sourceMappingURL=index.js.map