@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/src/client.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import {
2
2
  type EventMap,
3
3
  type ClientOptions,
4
+ type DatagramSocket,
5
+ type ResolvedAddr,
4
6
  type EventMiddleware,
5
7
  type AddrResolver,
6
- type ClientEnterEvent,
7
- type KickedEvent,
8
8
  type Listener,
9
9
  type ServerEvent,
10
10
  type ServerEventName,
11
11
  type Unsubscribe,
12
12
  ClientStatus,
13
13
  } from "./types.js";
14
+ import { ConnectPhase, type ClientTrace } from "./trace.js";
15
+ import { callHook } from "./hooks.js";
16
+ import { puzzleOf } from "./handshake/crypt-handshake.js";
14
17
  import {
15
18
  type ChannelID,
19
+ type ClientEnterEvent,
16
20
  type ClientID,
17
21
  type Codec,
22
+ type KickedEvent,
18
23
  type CommandMiddleware,
19
24
  type CommandOptions,
20
25
  type UID,
@@ -26,7 +31,7 @@ import {
26
31
  TeamspeakError,
27
32
  noopLogger,
28
33
  } from "@honeybbq/teamspeak-core";
29
- import { AlreadyConnectedError, CryptoInitError, RemovedError } from "./errors.js";
34
+ import { AlreadyConnectedError, HandshakeError, RemovedError } from "./errors.js";
30
35
  import { abortable, abortReason } from "./abort.js";
31
36
  import { Identity, Crypt } from "./crypto/index.js";
32
37
  import { RESOLVE_BUDGET_MS, Resolver } from "./discovery/resolver.js";
@@ -35,7 +40,7 @@ import { PacketType } from "./transport/packet.js";
35
40
  import type { Packet } from "./transport/packet.js";
36
41
  import { CommandTracker, appendReturnCode } from "./commands.js";
37
42
  import { encodeRequest, parseServerError } from "@honeybbq/teamspeak-core/internal";
38
- import { uploadFileData, downloadFileData } from "./transfer.js";
43
+ import { uploadFileData, downloadFileData, type FileTransferOptions } from "./transfer.js";
39
44
  import { buildCommandChain, buildEventChain } from "./events.js";
40
45
  import { processInit1 } from "./handshake/crypt-handshake.js";
41
46
  import {
@@ -103,7 +108,12 @@ export interface ClientInternals {
103
108
  clid: number;
104
109
  capabilities: ServerCapabilities;
105
110
  /** Mark the attempt in progress connected. */
106
- markConnected(): void;
111
+ markConnected(initServer?: Readonly<Record<string, string>>): void;
112
+ /**
113
+ * The shared secret exists: the attempt moves from its handshake to joining.
114
+ * `serverPublicKey` is the server's `omega`, as it sent it.
115
+ */
116
+ markHandshakeDone(serverPublicKey?: string): void;
107
117
  /**
108
118
  * Send a command line without waiting for an answer, on the handshake's
109
119
  * own path: it needs an open socket, not a connected session.
@@ -145,7 +155,47 @@ const DISCONNECT_TIMEOUT_MS = 1_000;
145
155
  /** How long each reconnection attempt may take. */
146
156
  const RECONNECT_ATTEMPT_TIMEOUT_MS = 30_000;
147
157
 
148
- /** One connection attempt, settled once by the handshake or its failure. */
158
+ /** What the trace hooks need to know about one attempt, from its first milestone on. */
159
+ interface AttemptTrace {
160
+ readonly attempt: number;
161
+ readonly reconnect: boolean;
162
+ /** When the address was about to be resolved. */
163
+ readonly startedAt: number;
164
+ /** When the socket was about to be opened. */
165
+ connectStartedAt: number;
166
+ phase: ConnectPhase;
167
+ /** The `host:port` being dialled; empty until the address is resolved. */
168
+ target: string;
169
+ sessionStarted: boolean;
170
+ welcomeDone: boolean;
171
+ /** The channel rows the welcome sequence has delivered so far. */
172
+ channels: number;
173
+ }
174
+
175
+ /** A session that began with `sessionStart`, until its `sessionEnd`. */
176
+ interface TracedSession {
177
+ readonly clientID: ClientID;
178
+ readonly startedAt: number;
179
+ readonly attempt: number;
180
+ readonly reconnect: boolean;
181
+ }
182
+
183
+ /** The `initserver` parameters a trace never sees, because they could carry a secret. */
184
+ const SECRET_INITSERVER_PARAMS: ReadonlySet<string> = new Set([
185
+ "client_server_password",
186
+ "client_default_channel_password",
187
+ "client_default_token",
188
+ "client_key_offset",
189
+ ]);
190
+
191
+ /**
192
+ * One connection attempt, settled once by the handshake or its failure.
193
+ *
194
+ * The settling is also what makes the trace's promise true: `sessionStart`
195
+ * is fired by whoever settles it as connected and `attemptFailed` by `reject`
196
+ * and `rejectAfter`, all behind the one `settled` flag, so an attempt gets
197
+ * exactly one of them.
198
+ */
149
199
  interface ConnectAttempt {
150
200
  readonly promise: Promise<void>;
151
201
  /**
@@ -155,6 +205,10 @@ interface ConnectAttempt {
155
205
  */
156
206
  readonly signal: AbortSignal;
157
207
  settled: boolean;
208
+ /** The trace state, once the attempt has started. */
209
+ trace: AttemptTrace | undefined;
210
+ /** Called once, if the attempt settles as failed. */
211
+ onFail: ((error: unknown) => void) | undefined;
158
212
  resolve(): void;
159
213
  reject(error: unknown): void;
160
214
  /**
@@ -181,6 +235,8 @@ function newConnectAttempt(): ConnectAttempt {
181
235
  promise,
182
236
  signal: controller.signal,
183
237
  settled: false,
238
+ trace: undefined,
239
+ onFail: undefined,
184
240
  resolve() {
185
241
  if (attempt.settled) return;
186
242
  attempt.settled = true;
@@ -189,12 +245,14 @@ function newConnectAttempt(): ConnectAttempt {
189
245
  reject(error) {
190
246
  if (attempt.settled) return;
191
247
  attempt.settled = true;
248
+ attempt.onFail?.(error);
192
249
  controller.abort(error);
193
250
  reject(error);
194
251
  },
195
252
  rejectAfter(error, until) {
196
253
  if (attempt.settled) return;
197
254
  attempt.settled = true;
255
+ attempt.onFail?.(error);
198
256
  controller.abort(error);
199
257
  void until.finally(() => reject(error));
200
258
  },
@@ -250,6 +308,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
250
308
  #clientInitOptions: ClientInitOptions;
251
309
  #socketFactory: SocketFactory | undefined;
252
310
  #fileTransferSocketFactory: FileTransferSocketFactory | undefined;
311
+ /** A copy of the caller's trace, so changing theirs later changes nothing. */
312
+ #trace: ClientTrace | undefined;
313
+ /** The session in progress, for its `sessionEnd`; cleared when that has been reported. */
314
+ #session: TracedSession | undefined;
253
315
  /** Undefined when the client does not reconnect. */
254
316
  #reconnectOptions: ReconnectOptions | undefined;
255
317
  /**
@@ -270,6 +332,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
270
332
  * the new one instead.
271
333
  */
272
334
  #leaving: Promise<void> | undefined;
335
+ /** Ends a disconnect()'s wait for its goodbye to be answered, while it waits. */
336
+ #goodbyeAnswered: (() => void) | undefined;
273
337
  /** How many times disconnect() was called, so a waiting connect() sees one. */
274
338
  #disconnects = 0;
275
339
  /**
@@ -309,6 +373,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
309
373
  this.#socketFactory = options.socketFactory;
310
374
  this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
311
375
  this.#reconnectOptions = options.reconnect;
376
+ this.#trace = options.trace === undefined ? undefined : { ...options.trace };
312
377
 
313
378
  this.#crypt = new Crypt(identity);
314
379
  this.#handler = this.#newHandler();
@@ -346,7 +411,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
346
411
  set capabilities(value) {
347
412
  self.#capabilities = value;
348
413
  },
349
- markConnected: () => this.#markConnected(),
414
+ markConnected: (initServer) => this.#markConnected(initServer),
415
+ markHandshakeDone: (serverPublicKey) => this.#markHandshakeDone(serverPublicKey),
350
416
  execNoWait: (command) => this.#execNoWait(command, undefined, true),
351
417
  handlePacket: (packet) => this.#handlePacket(packet),
352
418
  };
@@ -396,6 +462,14 @@ export class Client implements CommandExecutor, AsyncDisposable {
396
462
  * after `await client.connect()` still hears it.
397
463
  */
398
464
  async connect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
465
+ return this.#connect(options, 1, false);
466
+ }
467
+
468
+ async #connect(
469
+ options: { readonly signal?: AbortSignal | undefined },
470
+ attemptNumber: number,
471
+ reconnect: boolean,
472
+ ): Promise<void> {
399
473
  const { signal } = options;
400
474
  signal?.throwIfAborted();
401
475
  const disconnects = this.#disconnects;
@@ -413,6 +487,27 @@ export class Client implements CommandExecutor, AsyncDisposable {
413
487
  this.#resetForConnect();
414
488
  this.#status = ClientStatus.Connecting;
415
489
  const attempt = this.#attempt;
490
+ const trace: AttemptTrace = {
491
+ attempt: attemptNumber,
492
+ reconnect,
493
+ startedAt: performance.now(),
494
+ connectStartedAt: 0,
495
+ phase: ConnectPhase.Open,
496
+ target: "",
497
+ sessionStarted: false,
498
+ welcomeDone: false,
499
+ channels: 0,
500
+ };
501
+ attempt.trace = trace;
502
+ attempt.onFail = (error) =>
503
+ this.#callHook(this.#trace?.attemptFailed, () => ({
504
+ attempt: trace.attempt,
505
+ reconnect: trace.reconnect,
506
+ phase: trace.phase,
507
+ error,
508
+ target: trace.target,
509
+ durationMs: performance.now() - trace.startedAt,
510
+ }));
416
511
 
417
512
  // The handshake may complete just as the signal fires, in which case
418
513
  // #failConnection leaves the session alone and the caller gets the
@@ -421,10 +516,41 @@ export class Client implements CommandExecutor, AsyncDisposable {
421
516
  signal?.addEventListener("abort", onAbort, { once: true });
422
517
  try {
423
518
  try {
424
- const targetAddr = await this.#resolveAddr(attempt.signal);
519
+ this.#callHook(this.#trace?.resolveStart, () => ({
520
+ attempt: trace.attempt,
521
+ reconnect: trace.reconnect,
522
+ addr: this.#addr,
523
+ }));
524
+ const resolveStarted = performance.now();
525
+ const resolution = await this.#resolveAddr(attempt.signal);
526
+ const targetAddr = resolution.target;
527
+ trace.target = targetAddr;
528
+ if (!attempt.settled) {
529
+ this.#callHook(this.#trace?.resolveDone, () => ({
530
+ attempt: trace.attempt,
531
+ reconnect: trace.reconnect,
532
+ addr: this.#addr,
533
+ target: targetAddr,
534
+ source: resolution.source,
535
+ candidates: resolution.candidates,
536
+ error: resolution.error,
537
+ durationMs: performance.now() - resolveStarted,
538
+ }));
539
+ }
425
540
  if (!attempt.settled) {
426
541
  this.#logger.info("connecting to server", { address: targetAddr });
542
+ trace.connectStartedAt = performance.now();
543
+ this.#callHook(this.#trace?.connectStart, () => ({
544
+ attempt: trace.attempt,
545
+ reconnect: trace.reconnect,
546
+ target: targetAddr,
547
+ }));
548
+ }
549
+ if (!attempt.settled) {
427
550
  await this.#handler.connect(targetAddr, attempt.signal);
551
+ // The socket is open and the first packet is out: a failure from
552
+ // here to the shared secret is the handshake's.
553
+ if (trace.phase === ConnectPhase.Open) trace.phase = ConnectPhase.Handshake;
428
554
  }
429
555
  } catch (error) {
430
556
  // Without this the client stays Connecting and every later connect()
@@ -479,20 +605,35 @@ export class Client implements CommandExecutor, AsyncDisposable {
479
605
  this.#logger.info("disconnecting from server");
480
606
 
481
607
  if (wasConnected) {
482
- try {
483
- // On the internal path: the status already says Disconnected.
484
- await this.#exec(
485
- "clientdisconnect reasonmsg=Shutdown",
486
- { signal, timeout: DISCONNECT_TIMEOUT_MS },
487
- true,
488
- );
489
- } catch {
490
- // A courtesy; the connection closes regardless.
491
- }
608
+ this.#callHook(this.#trace?.disconnectStart, () => ({}));
609
+ const goodbyeStarted = performance.now();
610
+ // The server answers clientdisconnect with a notifyclientleftview about
611
+ // this client, and no error line; either one ends the wait. A goodbye
612
+ // that is not answered in time is a courtesy lost: the connection
613
+ // closes regardless.
614
+ const left = new Promise<boolean>((resolve) => {
615
+ this.#goodbyeAnswered = () => resolve(true);
616
+ });
617
+ // On the internal path: the status already says Disconnected.
618
+ const answered = this.#exec(
619
+ "clientdisconnect reasonmsg=Shutdown",
620
+ { signal, timeout: DISCONNECT_TIMEOUT_MS },
621
+ true,
622
+ ).then(
623
+ () => true,
624
+ () => false,
625
+ );
626
+ const acknowledged = await Promise.race([answered, left]);
627
+ this.#goodbyeAnswered = undefined;
628
+ this.#callHook(this.#trace?.disconnectDone, () => ({
629
+ acknowledged,
630
+ durationMs: performance.now() - goodbyeStarted,
631
+ }));
492
632
  }
493
633
 
494
634
  this.#handler.close();
495
635
  this.#cmdTrack.reset(new NotConnectedError());
636
+ this.#endSession(undefined);
496
637
  // Announced here; #handleConnectionClosed stays quiet once the status is
497
638
  // already Disconnected, so listeners hear about it once.
498
639
  await this.#announceDisconnected(undefined);
@@ -684,11 +825,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
684
825
  * ```
685
826
  *
686
827
  * Events that arrive while the loop body runs are buffered, so none is
687
- * missed — except voice, of which at most 256 frames are buffered; the rest
688
- * are dropped, as for a listener that falls behind. Iteration ends when
689
- * `signal` aborts, when the loop exits early (`break`, `return`, a throw),
690
- * and — for every event but `disconnected` — when the client disconnects.
691
- * An abort stops listening at once and discards whatever is buffered.
828
+ * missed. The buffer is unbounded: an iterator that is not consumed as fast
829
+ * as events arrive holds every one of them in memory until it is read, so
830
+ * stop iterating (or abort `signal`) when you stop reading. Voice is the
831
+ * exception: at most 256 `voiceData` frames are buffered, and the rest are
832
+ * dropped, as for a listener that falls behind.
833
+ *
834
+ * Iteration ends when the loop exits early (`break`, `return`, a throw), and
835
+ * — for every event but `disconnected` — when the client disconnects. When
836
+ * `signal` aborts, listening stops at once, whatever is buffered is
837
+ * discarded, and the pending and every later `next()` reject with the
838
+ * signal's reason, as `events.on` in Node does.
839
+ *
840
+ * `next()` may be called again before an earlier call has settled; the calls
841
+ * are answered in order.
692
842
  */
693
843
  events<K extends keyof EventMap>(
694
844
  event: K,
@@ -696,55 +846,72 @@ export class Client implements CommandExecutor, AsyncDisposable {
696
846
  ): AsyncIterableIterator<EventMap[K]> {
697
847
  const { signal } = options;
698
848
  const buffered: Array<EventMap[K]> = [];
699
- let wake: (() => void) | undefined;
849
+ // The next() calls waiting for an event, oldest first.
850
+ const waiting: Array<{
851
+ resolve: (result: IteratorResult<EventMap[K]>) => void;
852
+ reject: (reason: unknown) => void;
853
+ }> = [];
700
854
  let ended = false;
855
+ let aborted = false;
856
+
857
+ const finishWaiting = (): void => {
858
+ for (const waiter of waiting.splice(0)) {
859
+ waiter.resolve({ value: undefined, done: true });
860
+ }
861
+ };
701
862
 
702
863
  const stopListening = this.on(event, ((payload: EventMap[K]) => {
703
864
  if (ended) return;
865
+ const waiter = waiting.shift();
866
+ if (waiter) {
867
+ waiter.resolve({ value: payload, done: false });
868
+ return;
869
+ }
704
870
  if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
705
871
  this.#dropVoice();
706
872
  return;
707
873
  }
708
874
  buffered.push(payload);
709
- wake?.();
710
875
  }) as Listener<K>);
711
876
  // Ends after what is already buffered, which arrived before the disconnect.
712
877
  const stopOnDisconnect =
713
878
  event === "disconnected"
714
879
  ? undefined
715
880
  : this.on("disconnected", () => {
716
- ended = true;
717
- stopListening();
718
- wake?.();
881
+ cleanup();
882
+ finishWaiting();
719
883
  });
720
884
  const cleanup = (): void => {
721
885
  ended = true;
722
886
  stopListening();
723
887
  stopOnDisconnect?.();
724
888
  signal?.removeEventListener("abort", abort);
725
- wake?.();
726
889
  };
727
890
  const abort = (): void => {
891
+ aborted = true;
728
892
  buffered.length = 0;
729
893
  cleanup();
894
+ const reason = abortReason(signal!);
895
+ for (const waiter of waiting.splice(0)) waiter.reject(reason);
730
896
  };
731
897
  if (signal?.aborted) abort();
732
898
  else signal?.addEventListener("abort", abort, { once: true });
733
899
 
734
900
  const iterator: AsyncIterableIterator<EventMap[K]> = {
735
- async next(): Promise<IteratorResult<EventMap[K]>> {
736
- while (buffered.length === 0 && !ended) {
737
- await new Promise<void>((resolve) => (wake = resolve));
738
- wake = undefined;
901
+ next(): Promise<IteratorResult<EventMap[K]>> {
902
+ if (aborted) return Promise.reject(abortReason(signal!));
903
+ if (buffered.length > 0) return Promise.resolve({ value: buffered.shift()!, done: false });
904
+ if (ended) {
905
+ cleanup();
906
+ return Promise.resolve({ value: undefined, done: true });
739
907
  }
740
- if (buffered.length > 0) return { value: buffered.shift()!, done: false };
741
- cleanup();
742
- return { value: undefined, done: true };
908
+ return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
743
909
  },
744
- async return(): Promise<IteratorResult<EventMap[K]>> {
910
+ return(): Promise<IteratorResult<EventMap[K]>> {
745
911
  buffered.length = 0;
746
912
  cleanup();
747
- return { value: undefined, done: true };
913
+ finishWaiting();
914
+ return Promise.resolve({ value: undefined, done: true });
748
915
  },
749
916
  [Symbol.asyncIterator]() {
750
917
  return iterator;
@@ -814,7 +981,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
814
981
  host: string,
815
982
  info: FileUploadInfo,
816
983
  data: Readable,
817
- options: { readonly signal?: AbortSignal | undefined } = {},
984
+ options: Pick<FileTransferOptions, "signal" | "trace"> = {},
818
985
  ): Promise<void> {
819
986
  return uploadFileData(host, info, data, {
820
987
  ...options,
@@ -830,7 +997,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
830
997
  host: string,
831
998
  info: FileDownloadInfo,
832
999
  dest: Writable,
833
- options: { readonly signal?: AbortSignal | undefined } = {},
1000
+ options: Pick<FileTransferOptions, "signal" | "trace"> = {},
834
1001
  ): Promise<void> {
835
1002
  return downloadFileData(host, info, dest, {
836
1003
  ...options,
@@ -840,15 +1007,125 @@ export class Client implements CommandExecutor, AsyncDisposable {
840
1007
 
841
1008
  // ---- Internal (package-visible) ------------------------------------------
842
1009
 
843
- #markConnected(): void {
1010
+ #markConnected(initServer: Readonly<Record<string, string>> = {}): void {
844
1011
  // Only the attempt in progress can complete: never one that disconnect()
845
1012
  // or a failure has already ended.
846
1013
  if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
847
1014
  this.#status = ClientStatus.Connected;
1015
+ // Settled before the hook runs, so that nothing it does can turn this
1016
+ // attempt into a failed one; connect() resumes only after the hook returns.
848
1017
  this.#attempt.resolve();
1018
+ const trace = this.#attempt.trace;
1019
+ if (trace !== undefined) {
1020
+ trace.sessionStarted = true;
1021
+ this.#session = {
1022
+ clientID: asClientID(this.#clid),
1023
+ startedAt: performance.now(),
1024
+ attempt: trace.attempt,
1025
+ reconnect: trace.reconnect,
1026
+ };
1027
+ this.#callHook(this.#trace?.sessionStart, () => {
1028
+ const safe: Record<string, string> = {};
1029
+ for (const [key, value] of Object.entries(initServer)) {
1030
+ if (!SECRET_INITSERVER_PARAMS.has(key)) safe[key] = value;
1031
+ }
1032
+ const talkPower = Number.parseInt(initServer["client_talk_power"] ?? "", 10);
1033
+ return {
1034
+ attempt: trace.attempt,
1035
+ reconnect: trace.reconnect,
1036
+ clientID: asClientID(this.#clid),
1037
+ nickname: initServer["acn"] ?? this.#nickname,
1038
+ talkPower: Number.isNaN(talkPower) ? 0 : talkPower,
1039
+ initServer: Object.freeze(safe),
1040
+ capabilities: this.#capabilities,
1041
+ durationMs: performance.now() - trace.connectStartedAt,
1042
+ };
1043
+ });
1044
+ }
849
1045
  this.#emit("connected", undefined);
850
1046
  }
851
1047
 
1048
+ /** The socket is open: the attempt's handshake begins. */
1049
+ #markConnectDone(socket: DatagramSocket): void {
1050
+ const trace = this.#attempt.trace;
1051
+ if (trace === undefined || this.#attempt.settled || trace.phase !== ConnectPhase.Open) return;
1052
+ trace.phase = ConnectPhase.Handshake;
1053
+ this.#callHook(this.#trace?.connectDone, () => {
1054
+ let localAddr = "";
1055
+ try {
1056
+ const local = socket.address?.();
1057
+ if (local !== undefined) localAddr = joinHostPort(local.address, String(local.port));
1058
+ } catch {
1059
+ // A socket that cannot say where it is bound has no local address to report.
1060
+ }
1061
+ return {
1062
+ attempt: trace.attempt,
1063
+ reconnect: trace.reconnect,
1064
+ target: trace.target,
1065
+ localAddr,
1066
+ durationMs: performance.now() - trace.connectStartedAt,
1067
+ };
1068
+ });
1069
+ }
1070
+
1071
+ #markHandshakeDone(serverPublicKey = ""): void {
1072
+ const trace = this.#attempt.trace;
1073
+ if (trace === undefined || this.#attempt.settled || trace.phase !== ConnectPhase.Handshake) {
1074
+ return;
1075
+ }
1076
+ trace.phase = ConnectPhase.Join;
1077
+ this.#callHook(this.#trace?.handshakeDone, () => {
1078
+ const puzzle = puzzleOf(this.#crypt);
1079
+ return {
1080
+ attempt: trace.attempt,
1081
+ reconnect: trace.reconnect,
1082
+ durationMs: performance.now() - trace.connectStartedAt,
1083
+ puzzleLevel: puzzle.level,
1084
+ puzzleDurationMs: puzzle.durationMs,
1085
+ // The handler is this attempt's own, so its count starts at the socket.
1086
+ resends: this.#handler.resends,
1087
+ serverPublicKey,
1088
+ };
1089
+ });
1090
+ }
1091
+
1092
+ #markWelcomeDone(): void {
1093
+ const trace = this.#attempt.trace;
1094
+ if (trace === undefined || !trace.sessionStarted || trace.welcomeDone) return;
1095
+ if (this.#status !== ClientStatus.Connected) return;
1096
+ trace.welcomeDone = true;
1097
+ this.#callHook(this.#trace?.welcomeDone, () => ({
1098
+ attempt: trace.attempt,
1099
+ reconnect: trace.reconnect,
1100
+ channelID: this.channelID,
1101
+ channels: trace.channels,
1102
+ clients: this.#clients.size,
1103
+ durationMs: performance.now() - trace.connectStartedAt,
1104
+ }));
1105
+ }
1106
+
1107
+ /** Report the end of the session in progress, once. */
1108
+ #endSession(error: unknown): void {
1109
+ const session = this.#session;
1110
+ if (session === undefined) return;
1111
+ this.#session = undefined;
1112
+ this.#callHook(this.#trace?.sessionEnd, () => ({
1113
+ clientID: session.clientID,
1114
+ error,
1115
+ durationMs: performance.now() - session.startedAt,
1116
+ attempt: session.attempt,
1117
+ reconnect: session.reconnect,
1118
+ }));
1119
+ }
1120
+
1121
+ /**
1122
+ * Call a trace hook, if there is one, and never let it touch the
1123
+ * connection: see callHook. The info is built only when there is a hook.
1124
+ */
1125
+ #callHook<I>(hook: ((info: I) => unknown) | undefined, info: () => I): void {
1126
+ callHook(hook, this.#trace, info);
1127
+ }
1128
+
852
1129
  // ---- Private -------------------------------------------------------------
853
1130
 
854
1131
  #newHandler(): PacketHandler {
@@ -862,6 +1139,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
862
1139
  handler.onClosed = (err) => {
863
1140
  if (handler === this.#handler) this.#handleConnectionClosed(err);
864
1141
  };
1142
+ handler.onOpen = (socket) => {
1143
+ if (handler === this.#handler) this.#markConnectDone(socket);
1144
+ };
865
1145
  handler.onReceiveError = (error) => {
866
1146
  if (handler === this.#handler) this.#handleReceiveError(error);
867
1147
  };
@@ -879,7 +1159,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
879
1159
  this.#capabilities = unknownServerCapabilities();
880
1160
  }
881
1161
 
882
- async #resolveAddr(signal: AbortSignal): Promise<string> {
1162
+ async #resolveAddr(signal: AbortSignal): Promise<{
1163
+ target: string;
1164
+ source: string;
1165
+ candidates: readonly ResolvedAddr[];
1166
+ error: unknown;
1167
+ }> {
883
1168
  // Parsed, not tested for a colon: an IPv6 literal has colons and no port.
884
1169
  const { host, port } = splitHostPort(this.#addr);
885
1170
  const fallback = joinHostPort(host, port);
@@ -899,13 +1184,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
899
1184
  this.#resolver.resolve(this.#addr, { signal: lookup }),
900
1185
  lookup,
901
1186
  );
902
- return resolved[0]?.addr ?? fallback;
1187
+ const first = resolved[0];
1188
+ return {
1189
+ target: first?.addr ?? fallback,
1190
+ source: first?.source ?? "",
1191
+ candidates: resolved,
1192
+ error: undefined,
1193
+ };
903
1194
  } catch (error) {
904
1195
  signal.throwIfAborted();
1196
+ const reason = budget.signal.aborted ? budget.signal.reason : error;
905
1197
  this.#logger.warn("address resolution failed, falling back to direct", {
906
- error: String(budget.signal.aborted ? budget.signal.reason : error),
1198
+ error: String(reason),
907
1199
  });
908
- return fallback;
1200
+ return { target: fallback, source: "", candidates: [], error: reason };
909
1201
  } finally {
910
1202
  clearTimeout(timer);
911
1203
  }
@@ -913,15 +1205,15 @@ export class Client implements CommandExecutor, AsyncDisposable {
913
1205
 
914
1206
  /**
915
1207
  * A datagram from the server could not be handled. During the handshake that
916
- * is the connection failing, reported as a CryptoInitError with the cause;
1208
+ * is the connection failing, reported as a HandshakeError with the cause;
917
1209
  * once connected, one bad packet is not worth the session, so it is dropped.
918
1210
  */
919
1211
  #handleReceiveError(error: unknown): void {
920
1212
  if (this.#status !== ClientStatus.Connected) {
921
1213
  this.#failConnection(
922
- error instanceof CryptoInitError
1214
+ error instanceof HandshakeError
923
1215
  ? error
924
- : new CryptoInitError("the handshake failed on a malformed packet", { cause: error }),
1216
+ : new HandshakeError("the handshake failed on a malformed packet", { cause: error }),
925
1217
  );
926
1218
  return;
927
1219
  }
@@ -1017,9 +1309,25 @@ export class Client implements CommandExecutor, AsyncDisposable {
1017
1309
  // which is how a channellist could come back with duplicate and empty rows.
1018
1310
  if (cmd.name === "channellistfinished") {
1019
1311
  this.#cmdTrack.signalWelcomeComplete();
1312
+ this.#markWelcomeDone();
1020
1313
  return;
1021
1314
  }
1022
1315
 
1316
+ if (cmd.name === "channellist") {
1317
+ const trace = this.#attempt.trace;
1318
+ if (trace !== undefined && !trace.welcomeDone) trace.channels++;
1319
+ }
1320
+
1321
+ // The server answers our goodbye by announcing that we left, not with an
1322
+ // error line; that announcement is what disconnect() waits for.
1323
+ if (
1324
+ cmd.name === "notifyclientleftview" &&
1325
+ this.#goodbyeAnswered !== undefined &&
1326
+ cmd.params["clid"] === String(this.#clid)
1327
+ ) {
1328
+ this.#goodbyeAnswered();
1329
+ }
1330
+
1023
1331
  if (cmd.name.startsWith("notify")) {
1024
1332
  // Some commands are answered by a notification instead of data rows;
1025
1333
  // it still goes on to be dispatched as an event, since the same
@@ -1174,7 +1482,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
1174
1482
  */
1175
1483
  #dropVoice(): void {
1176
1484
  this.#voiceDropped++;
1177
- const now = Date.now();
1485
+ const now = performance.now();
1178
1486
  if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
1179
1487
  this.#voiceDropWarnedAt = now;
1180
1488
  // The count since the last warning, so each one says how bad it is now.
@@ -1272,6 +1580,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
1272
1580
  // Never undefined for a drop: undefined means disconnect() only.
1273
1581
  const cause = err ?? new TeamspeakError("connection closed");
1274
1582
  this.#lastDrop = cause;
1583
+ this.#endSession(cause);
1275
1584
 
1276
1585
  // Announced first, then retried: a caller watching `disconnected` should
1277
1586
  // hear about the drop even when a reconnect is already on its way, because
@@ -1302,27 +1611,43 @@ export class Client implements CommandExecutor, AsyncDisposable {
1302
1611
 
1303
1612
  const stop = new AbortController();
1304
1613
  this.#stopReconnecting = stop;
1614
+ // The attempts the loop has made, and why it stopped, for `reconnectStopped`.
1615
+ let made = 0;
1616
+ let stoppedBy: { readonly cause: unknown } | undefined;
1305
1617
  try {
1306
1618
  for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
1307
- if (this.#stopped) return;
1619
+ if (this.#stopped) {
1620
+ stoppedBy = { cause: undefined };
1621
+ return;
1622
+ }
1308
1623
  let retry: boolean;
1309
1624
  try {
1310
1625
  retry = shouldRetry(err, attempt);
1311
1626
  } catch (error) {
1312
1627
  this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
1628
+ stoppedBy = { cause: err };
1313
1629
  return;
1314
1630
  }
1315
1631
  if (!retry) {
1316
1632
  this.#logger.info("not retrying", { attempt, reason: err.message });
1633
+ stoppedBy = { cause: err };
1317
1634
  return;
1318
1635
  }
1319
1636
 
1320
1637
  const delay = backoffDelay(policy, attempt);
1321
1638
  this.#logger.info("reconnecting", { attempt, delayMs: delay });
1639
+ this.#callHook(this.#trace?.reconnectScheduled, () => ({
1640
+ attempt,
1641
+ delayMs: delay,
1642
+ cause: err,
1643
+ }));
1322
1644
  // A timer that keeps the process alive: an application that does
1323
1645
  // nothing but stay connected would otherwise exit during the wait.
1324
1646
  await sleep(delay, stop.signal);
1325
- if (this.#stopped) return;
1647
+ if (this.#stopped) {
1648
+ stoppedBy = { cause: undefined };
1649
+ return;
1650
+ }
1326
1651
  // Connected, or connecting, by hand during the wait: nothing is left to
1327
1652
  // retry, and an attempt now would only fail with AlreadyConnectedError.
1328
1653
  if (this.#status !== ClientStatus.Disconnected) {
@@ -1331,7 +1656,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
1331
1656
  }
1332
1657
 
1333
1658
  try {
1334
- await this.connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) });
1659
+ made++;
1660
+ await this.#connect(
1661
+ { signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) },
1662
+ attempt,
1663
+ true,
1664
+ );
1335
1665
  // Read afresh: the status may have changed while connect() resolved.
1336
1666
  if (this.status === ClientStatus.Connected) {
1337
1667
  this.#logger.info("reconnected", { attempt });
@@ -1343,11 +1673,19 @@ export class Client implements CommandExecutor, AsyncDisposable {
1343
1673
  err = this.#lastDrop ?? new TeamspeakError("connection closed");
1344
1674
  } catch (retryErr) {
1345
1675
  if (retryErr instanceof AlreadyConnectedError) return;
1346
- err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr));
1676
+ err =
1677
+ retryErr instanceof Error
1678
+ ? retryErr
1679
+ : new TeamspeakError(String(retryErr), { cause: retryErr });
1347
1680
  }
1348
1681
  }
1349
1682
  this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
1683
+ stoppedBy = { cause: err };
1350
1684
  } finally {
1685
+ if (stoppedBy !== undefined) {
1686
+ const { cause } = stoppedBy;
1687
+ this.#callHook(this.#trace?.reconnectStopped, () => ({ attempts: made, cause }));
1688
+ }
1351
1689
  this.#reconnecting = false;
1352
1690
  if (this.#stopReconnecting === stop) this.#stopReconnecting = undefined;
1353
1691
  }
@@ -1371,7 +1709,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
1371
1709
  this.#status = ClientStatus.Disconnected;
1372
1710
  this.#handler.close();
1373
1711
  void this.#announceDisconnected(
1374
- error instanceof Error ? error : new TeamspeakError(String(error)),
1712
+ error instanceof Error ? error : new TeamspeakError(String(error), { cause: error }),
1375
1713
  );
1376
1714
  }
1377
1715
  }