@honeybbq/teamspeak 1.0.0-beta.19 → 1.0.0-beta.21

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 (179) hide show
  1. package/dist/client/api/avatars.d.ts.map +1 -1
  2. package/dist/client/api/avatars.js +11 -2
  3. package/dist/client/api/avatars.js.map +1 -1
  4. package/dist/client/api/files.d.ts +36 -14
  5. package/dist/client/api/files.d.ts.map +1 -1
  6. package/dist/client/api/files.js +58 -9
  7. package/dist/client/api/files.js.map +1 -1
  8. package/dist/client/api/upload-checks.d.ts +36 -0
  9. package/dist/client/api/upload-checks.d.ts.map +1 -0
  10. package/dist/client/api/upload-checks.js +77 -0
  11. package/dist/client/api/upload-checks.js.map +1 -0
  12. package/dist/client/api/voice.d.ts +2 -1
  13. package/dist/client/api/voice.d.ts.map +1 -1
  14. package/dist/client/api/voice.js +6 -2
  15. package/dist/client/api/voice.js.map +1 -1
  16. package/dist/client/client.d.ts +13 -3
  17. package/dist/client/client.d.ts.map +1 -1
  18. package/dist/client/client.js +151 -49
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/client/crypto/crypt.d.ts +6 -0
  21. package/dist/client/crypto/crypt.d.ts.map +1 -1
  22. package/dist/client/crypto/crypt.js +13 -7
  23. package/dist/client/crypto/crypt.js.map +1 -1
  24. package/dist/client/crypto/eax.d.ts +4 -0
  25. package/dist/client/crypto/eax.d.ts.map +1 -1
  26. package/dist/client/crypto/eax.js +27 -21
  27. package/dist/client/crypto/eax.js.map +1 -1
  28. package/dist/client/errors.d.ts +13 -0
  29. package/dist/client/errors.d.ts.map +1 -1
  30. package/dist/client/errors.js +23 -0
  31. package/dist/client/errors.js.map +1 -1
  32. package/dist/client/handshake/crypt-handshake.d.ts +2 -1
  33. package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
  34. package/dist/client/handshake/crypt-handshake.js +4 -2
  35. package/dist/client/handshake/crypt-handshake.js.map +1 -1
  36. package/dist/client/index.d.ts +1 -1
  37. package/dist/client/index.d.ts.map +1 -1
  38. package/dist/client/index.js +1 -1
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/throttle.d.ts +11 -0
  41. package/dist/client/throttle.d.ts.map +1 -1
  42. package/dist/client/throttle.js +38 -3
  43. package/dist/client/throttle.js.map +1 -1
  44. package/dist/client/transfer.d.ts +37 -4
  45. package/dist/client/transfer.d.ts.map +1 -1
  46. package/dist/client/transfer.js +155 -15
  47. package/dist/client/transfer.js.map +1 -1
  48. package/dist/client/transport/handler.d.ts.map +1 -1
  49. package/dist/client/transport/handler.js +47 -43
  50. package/dist/client/transport/handler.js.map +1 -1
  51. package/dist/core/api/clients.d.ts +15 -15
  52. package/dist/core/api/clients.d.ts.map +1 -1
  53. package/dist/core/api/clients.js.map +1 -1
  54. package/dist/core/api/command.d.ts +8 -6
  55. package/dist/core/api/command.d.ts.map +1 -1
  56. package/dist/core/api/command.js +1 -1
  57. package/dist/core/api/command.js.map +1 -1
  58. package/dist/core/api/files.d.ts +23 -6
  59. package/dist/core/api/files.d.ts.map +1 -1
  60. package/dist/core/api/files.js +12 -9
  61. package/dist/core/api/files.js.map +1 -1
  62. package/dist/core/api/groups.d.ts +17 -2
  63. package/dist/core/api/groups.d.ts.map +1 -1
  64. package/dist/core/api/groups.js +13 -11
  65. package/dist/core/api/groups.js.map +1 -1
  66. package/dist/core/api/parse.d.ts +5 -5
  67. package/dist/core/api/parse.d.ts.map +1 -1
  68. package/dist/core/api/parse.js +16 -15
  69. package/dist/core/api/parse.js.map +1 -1
  70. package/dist/core/command/request.d.ts +6 -0
  71. package/dist/core/command/request.d.ts.map +1 -1
  72. package/dist/core/command/request.js +12 -0
  73. package/dist/core/command/request.js.map +1 -1
  74. package/dist/core/errors.d.ts +46 -3
  75. package/dist/core/errors.d.ts.map +1 -1
  76. package/dist/core/errors.js +73 -20
  77. package/dist/core/errors.js.map +1 -1
  78. package/dist/core/events.d.ts +24 -8
  79. package/dist/core/events.d.ts.map +1 -1
  80. package/dist/core/internal.d.ts +1 -1
  81. package/dist/core/internal.d.ts.map +1 -1
  82. package/dist/core/internal.js +1 -1
  83. package/dist/core/internal.js.map +1 -1
  84. package/dist/core/notifications.d.ts +1 -1
  85. package/dist/core/notifications.js +9 -8
  86. package/dist/core/notifications.js.map +1 -1
  87. package/dist/core/reconnect.d.ts +16 -5
  88. package/dist/core/reconnect.d.ts.map +1 -1
  89. package/dist/core/reconnect.js +32 -4
  90. package/dist/core/reconnect.js.map +1 -1
  91. package/dist/core/types.d.ts +15 -16
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/query/client.d.ts +4 -0
  95. package/dist/query/client.d.ts.map +1 -1
  96. package/dist/query/client.js +145 -23
  97. package/dist/query/client.js.map +1 -1
  98. package/dist/query/errors.d.ts +20 -0
  99. package/dist/query/errors.d.ts.map +1 -1
  100. package/dist/query/errors.js +31 -0
  101. package/dist/query/errors.js.map +1 -1
  102. package/dist/query/index.d.ts +1 -1
  103. package/dist/query/index.d.ts.map +1 -1
  104. package/dist/query/index.js +1 -1
  105. package/dist/query/index.js.map +1 -1
  106. package/dist/query/lines.d.ts +11 -0
  107. package/dist/query/lines.d.ts.map +1 -1
  108. package/dist/query/lines.js +36 -6
  109. package/dist/query/lines.js.map +1 -1
  110. package/dist/query/options.d.ts +30 -0
  111. package/dist/query/options.d.ts.map +1 -1
  112. package/dist/query/options.js +7 -0
  113. package/dist/query/options.js.map +1 -1
  114. package/dist/query/reconnect.d.ts +5 -0
  115. package/dist/query/reconnect.d.ts.map +1 -1
  116. package/dist/query/reconnect.js +26 -7
  117. package/dist/query/reconnect.js.map +1 -1
  118. package/dist/query/ssh.js +9 -4
  119. package/dist/query/ssh.js.map +1 -1
  120. package/dist/query/stream.d.ts +2 -0
  121. package/dist/query/stream.d.ts.map +1 -1
  122. package/dist/query/stream.js +19 -1
  123. package/dist/query/stream.js.map +1 -1
  124. package/dist/query/transport.d.ts +12 -2
  125. package/dist/query/transport.d.ts.map +1 -1
  126. package/dist/query/web.d.ts +3 -1
  127. package/dist/query/web.d.ts.map +1 -1
  128. package/dist/query/web.js +82 -18
  129. package/dist/query/web.js.map +1 -1
  130. package/dist/stream/published.d.ts.map +1 -1
  131. package/dist/stream/published.js +61 -25
  132. package/dist/stream/published.js.map +1 -1
  133. package/dist/stream/streams.d.ts.map +1 -1
  134. package/dist/stream/streams.js +51 -10
  135. package/dist/stream/streams.js.map +1 -1
  136. package/dist/stream/wire.d.ts +2 -2
  137. package/dist/stream/wire.d.ts.map +1 -1
  138. package/dist/stream/wire.js +27 -7
  139. package/dist/stream/wire.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/client/api/avatars.ts +15 -2
  142. package/src/client/api/files.ts +83 -23
  143. package/src/client/api/upload-checks.ts +96 -0
  144. package/src/client/api/voice.ts +5 -2
  145. package/src/client/client.ts +154 -42
  146. package/src/client/crypto/crypt.ts +15 -7
  147. package/src/client/crypto/eax.ts +27 -22
  148. package/src/client/errors.ts +25 -0
  149. package/src/client/handshake/crypt-handshake.ts +6 -3
  150. package/src/client/index.ts +1 -0
  151. package/src/client/throttle.ts +41 -3
  152. package/src/client/transfer.ts +172 -28
  153. package/src/client/transport/handler.ts +54 -46
  154. package/src/core/api/clients.ts +24 -21
  155. package/src/core/api/command.ts +9 -7
  156. package/src/core/api/files.ts +30 -16
  157. package/src/core/api/groups.ts +32 -13
  158. package/src/core/api/parse.ts +16 -15
  159. package/src/core/command/request.ts +18 -0
  160. package/src/core/errors.ts +74 -20
  161. package/src/core/events.ts +24 -8
  162. package/src/core/internal.ts +7 -1
  163. package/src/core/notifications.ts +9 -8
  164. package/src/core/reconnect.ts +60 -8
  165. package/src/core/types.ts +15 -16
  166. package/src/query/client.ts +144 -20
  167. package/src/query/errors.ts +34 -0
  168. package/src/query/index.ts +1 -0
  169. package/src/query/lines.ts +38 -6
  170. package/src/query/options.ts +36 -0
  171. package/src/query/reconnect.ts +27 -7
  172. package/src/query/ssh.ts +11 -4
  173. package/src/query/stream.ts +17 -1
  174. package/src/query/testing/live.ts +4 -4
  175. package/src/query/transport.ts +10 -2
  176. package/src/query/web.ts +76 -21
  177. package/src/stream/published.ts +70 -23
  178. package/src/stream/streams.ts +47 -13
  179. package/src/stream/wire.ts +30 -11
@@ -50,6 +50,9 @@ import {
50
50
  buildCommand,
51
51
  buildCommandChain,
52
52
  encodeRequest,
53
+ floodHoldMs,
54
+ FLOOD_RETRIES,
55
+ floodRetryDelayMs,
53
56
  parseServerError,
54
57
  resolveBackoffPolicy,
55
58
  } from "../core/internal.js";
@@ -468,6 +471,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
468
471
  this.#clientInitOptions = normalizeClientInitOptions(options);
469
472
  this.#socketFactory = options.socketFactory;
470
473
  this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
474
+ // Refused here, not when the loop first runs: a bad policy would flood.
475
+ if (options.reconnect !== undefined) resolveBackoffPolicy(options.reconnect);
471
476
  this.#reconnectOptions = options.reconnect;
472
477
  this.#trace = options.trace === undefined ? undefined : { ...options.trace };
473
478
  this.#viewStore = new ViewStore(
@@ -696,7 +701,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
696
701
  * Leave the server and close the connection.
697
702
  *
698
703
  * Says goodbye first, waiting for the server's acknowledgement for a second
699
- * at most, or less if `signal` aborts sooner. The connection is closed
704
+ * at most, or less if `signal` aborts sooner. The goodbye is sent at once,
705
+ * past the flood throttle, and also by a client still connecting whose
706
+ * clientinit is out; commands still waiting for their turn reject with a
707
+ * NotConnectedError and are never sent. The connection is closed
700
708
  * either way. A `connect()` still waiting for its handshake rejects with a
701
709
  * NotConnectedError, and so do the commands still waiting for an answer.
702
710
  * Resolves once `disconnected` has been delivered to the listeners, so one
@@ -728,13 +736,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
728
736
  // still be on its way.
729
737
  if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
730
738
 
731
- const wasConnected = this.#status === ClientStatus.Connected;
739
+ // The server may hold a session for this client from the moment clientinit
740
+ // is out, which is as soon as the keys exist, well before Connected.
741
+ const holdsSession =
742
+ this.#status === ClientStatus.Connected ||
743
+ (this.#status === ClientStatus.Connecting && this.#crypt.cryptoInitComplete);
732
744
  this.#status = ClientStatus.Disconnected;
745
+ // Whatever still waits for its turn is rejected and never sent; the
746
+ // goodbye does not wait for one.
747
+ this.#throttle.rejectAll(new NotConnectedError());
733
748
  let left!: () => void;
734
749
  this.#attempt.rejectAfter(new NotConnectedError(), new Promise<void>((r) => (left = r)));
735
750
 
736
751
  const leaving = this.#leave(
737
- wasConnected,
752
+ holdsSession,
738
753
  options.signal,
739
754
  options.reasonMessage ?? "Shutdown",
740
755
  ).finally(left);
@@ -747,13 +762,13 @@ export class Client implements CommandExecutor, AsyncDisposable {
747
762
  }
748
763
 
749
764
  async #leave(
750
- wasConnected: boolean,
765
+ holdsSession: boolean,
751
766
  signal: AbortSignal | undefined,
752
767
  reasonMessage: string,
753
768
  ): Promise<void> {
754
769
  this.#logger.info("disconnecting from server");
755
770
 
756
- if (wasConnected) {
771
+ if (holdsSession) {
757
772
  this.#callHook(this.#trace?.disconnectStart, () => ({}));
758
773
  const goodbyeStarted = performance.now();
759
774
  // The server answers clientdisconnect with a notifyclientleftview about
@@ -771,6 +786,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
771
786
  }),
772
787
  { signal, timeoutMs: DISCONNECT_TIMEOUT_MS },
773
788
  true,
789
+ true,
774
790
  ).then(
775
791
  () => true,
776
792
  () => false,
@@ -806,6 +822,11 @@ export class Client implements CommandExecutor, AsyncDisposable {
806
822
  *
807
823
  * Rejects with a NotConnectedError unless the client is connected: while it
808
824
  * is still connecting, the session's keys may not exist yet.
825
+ *
826
+ * A command the server refuses as flooding (`ErrorCode.ClientIsFlooding`)
827
+ * with a time to wait is sent again after it, and every other command waits
828
+ * too, up to three times and while its timeout allows; the refusal that
829
+ * remains rejects as it is.
809
830
  */
810
831
  // oxlint-disable-next-line typescript/array-type -- the public API report spells this `[]`; respelling it changes the report
811
832
  exec(command: string, options: CommandOptions = {}): Promise<Record<string, string>[]> {
@@ -873,18 +894,28 @@ export class Client implements CommandExecutor, AsyncDisposable {
873
894
  command: string,
874
895
  options: CommandOptions,
875
896
  internal: boolean,
897
+ goodbye = false,
876
898
  ): Promise<Array<Record<string, string>>> {
877
- if (!commandChannel.hasSubscribers) return this.#execUntraced(command, options, internal);
878
- const trace: CommandTrace = { command: parseCommand(command)?.name ?? "", client: this };
879
- return commandChannel.tracePromise(() => this.#execUntraced(command, options, internal), trace);
899
+ if (!commandChannel.hasSubscribers) {
900
+ return this.#execUntraced(command, options, internal, goodbye);
901
+ }
902
+ const trace: CommandTrace = {
903
+ command: parseCommand(command)?.name ?? "",
904
+ client: this,
905
+ };
906
+ return commandChannel.tracePromise(
907
+ () => this.#execUntraced(command, options, internal, goodbye),
908
+ trace,
909
+ );
880
910
  }
881
911
 
882
912
  async #execUntraced(
883
913
  command: string,
884
914
  options: CommandOptions,
885
915
  internal: boolean,
916
+ goodbye: boolean,
886
917
  ): Promise<Array<Record<string, string>>> {
887
- const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
918
+ const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, onSent } = options;
888
919
  signal?.throwIfAborted();
889
920
  // A command sent on no socket used to vanish and surface 10 seconds later
890
921
  // as a timeout, which reads like a slow server rather than a usage error.
@@ -898,7 +929,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
898
929
  const correlation = key === undefined ? undefined : parsed?.params[key];
899
930
  const echo = spec?.echoKey === undefined ? undefined : parsed?.params[spec.echoKey];
900
931
 
901
- const [rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
932
+ let [rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
933
+ const deadline = performance.now() + timeoutMs;
902
934
  let timer: ReturnType<typeof setTimeout> | undefined;
903
935
  let onAbort: (() => void) | undefined;
904
936
  // Started before the throttle: the timeout bounds the whole command, its
@@ -911,19 +943,45 @@ export class Client implements CommandExecutor, AsyncDisposable {
911
943
  givenUp.catch(() => {});
912
944
  const throttleAbort = new AbortController();
913
945
  try {
914
- const withRc = appendReturnCode(command, rc);
915
- await Promise.race([this.#throttle.wait(throttleAbort.signal), givenUp]);
916
- if (!this.#canSend(internal)) throw new NotConnectedError();
917
- this.#sendLine(withRc);
918
-
919
- const result = await Promise.race([answered, givenUp]);
920
- if (result.err) throw result.err;
921
- // The server took the rename, so it is what this client now asks for.
922
- if (!internal && parsed?.name === "clientupdate") {
923
- const renamed = parsed.params["client_nickname"];
924
- if (renamed !== undefined) this.#requestedNickname = renamed;
946
+ for (let refused = 0; ; refused++) {
947
+ const withRc = appendReturnCode(command, rc);
948
+ // The goodbye of a disconnect goes out at once: queued behind other
949
+ // commands it would not leave in time, and the server would keep the
950
+ // session.
951
+ if (!goodbye) await Promise.race([this.#throttle.wait(throttleAbort.signal), givenUp]);
952
+ if (!this.#canSend(internal)) throw new NotConnectedError();
953
+ this.#sendLine(withRc);
954
+ if (refused === 0) onSent?.();
955
+
956
+ const result = await Promise.race([answered, givenUp]);
957
+ // Refused as flooding with a time to wait, which the command can
958
+ // afford: nothing is sent until then, and the command, which the
959
+ // server did not run, goes out again.
960
+ const wait = result.err ? floodRetryDelayMs(result.err) : undefined;
961
+ const hold = wait === undefined ? undefined : floodHoldMs(wait);
962
+ if (
963
+ hold !== undefined &&
964
+ !goodbye &&
965
+ refused < FLOOD_RETRIES &&
966
+ hold < deadline - performance.now()
967
+ ) {
968
+ this.#logger.debug("the server asked for a pause", {
969
+ command: parsed?.name,
970
+ waitMs: wait,
971
+ });
972
+ this.#throttle.hold(hold);
973
+ this.#cmdTrack.unregister(rc);
974
+ [rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
975
+ continue;
976
+ }
977
+ if (result.err) throw result.err;
978
+ // The server took the rename, so it is what this client now asks for.
979
+ if (!internal && parsed?.name === "clientupdate") {
980
+ const renamed = parsed.params["client_nickname"];
981
+ if (renamed !== undefined) this.#requestedNickname = renamed;
982
+ }
983
+ return result.data;
925
984
  }
926
- return result.data;
927
985
  } finally {
928
986
  // A command given up while it waited for its turn gives the turn back.
929
987
  throttleAbort.abort();
@@ -1159,8 +1217,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
1159
1217
  }
1160
1218
 
1161
1219
  /**
1162
- * Send one encoded audio frame to the channel this client is in. Throws a
1163
- * NotConnectedError unless the client is connected.
1220
+ * Send one encoded audio frame to the channel this client is in. An empty
1221
+ * frame marks the end of speech: the server relays it, and listeners get a
1222
+ * `voiceData` event with empty `data`. Throws a NotConnectedError unless the
1223
+ * client is connected.
1164
1224
  */
1165
1225
  sendVoice(data: Uint8Array, codec: Codec, options?: VoiceOptions): void {
1166
1226
  // Reserved: VoiceOptions has no fields to read yet.
@@ -1213,7 +1273,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
1213
1273
  // A privilege key is spent by the join that used it: a reconnection must
1214
1274
  // not present it again.
1215
1275
  if (this.#clientInitOptions.privilegeKey !== "") {
1216
- this.#clientInitOptions = { ...this.#clientInitOptions, privilegeKey: "" };
1276
+ this.#clientInitOptions = {
1277
+ ...this.#clientInitOptions,
1278
+ privilegeKey: "",
1279
+ };
1217
1280
  }
1218
1281
  // Settled before the hook runs, so that nothing it does can turn this
1219
1282
  // attempt into a failed one; connect() resumes only after the hook returns.
@@ -1417,7 +1480,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
1417
1480
  this.#failConnection(
1418
1481
  error instanceof HandshakeError
1419
1482
  ? error
1420
- : new HandshakeError("the handshake failed on a malformed packet", { cause: error }),
1483
+ : new HandshakeError("the handshake failed on a malformed packet", {
1484
+ cause: error,
1485
+ }),
1421
1486
  );
1422
1487
  return;
1423
1488
  }
@@ -1456,7 +1521,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
1456
1521
  return;
1457
1522
  }
1458
1523
 
1459
- if ((pType === 0 /* Voice */ || pType === 1) /* VoiceWhisper */ && p.data.length > 5) {
1524
+ if ((pType === 0 /* Voice */ || pType === 1) /* VoiceWhisper */ && p.data.length >= 5) {
1460
1525
  this.#handleVoicePacket(p.data);
1461
1526
  return;
1462
1527
  }
@@ -1469,6 +1534,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
1469
1534
  /**
1470
1535
  * Parse an incoming S2C voice packet.
1471
1536
  * Format: [VId: u16] [CId: u16] [Codec: u8] [Data: var]
1537
+ *
1538
+ * A packet with no data is how a speaker's last frame is followed: the
1539
+ * server relays the empty frame a client sends when it stops talking, and it
1540
+ * is delivered as an event with empty `data`. Measured on TeamSpeak 3 3.13.8
1541
+ * and TeamSpeak 6 6.0.0: the frames of `sendVoice` arrive as 45-byte packets
1542
+ * and an empty one as a 5-byte packet.
1472
1543
  */
1473
1544
  #handleVoicePacket(payload: Uint8Array): void {
1474
1545
  // Audio arrives fifty times a second per speaker; build no event nobody
@@ -1656,7 +1727,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
1656
1727
  #answerConnectionInfoRequest(): void {
1657
1728
  const line = buildSetConnectionInfo(this.#handler.trafficSnapshot(), this.#handler.roundTrip);
1658
1729
  this.#execNoWait(line, undefined, false).catch((error: unknown) => {
1659
- this.#logger.debug("could not answer a connection info request", { error: String(error) });
1730
+ this.#logger.debug("could not answer a connection info request", {
1731
+ error: String(error),
1732
+ });
1660
1733
  });
1661
1734
  }
1662
1735
 
@@ -1754,17 +1827,29 @@ export class Client implements CommandExecutor, AsyncDisposable {
1754
1827
  this.#emitServerEvent({ name: "clientUpdated", payload: result.event });
1755
1828
  break;
1756
1829
  case "clientServerGroupAdded":
1757
- this.#emitServerEvent({ name: "clientServerGroupAdded", payload: result.event });
1830
+ this.#emitServerEvent({
1831
+ name: "clientServerGroupAdded",
1832
+ payload: result.event,
1833
+ });
1758
1834
  break;
1759
1835
  case "clientServerGroupRemoved":
1760
- this.#emitServerEvent({ name: "clientServerGroupRemoved", payload: result.event });
1836
+ this.#emitServerEvent({
1837
+ name: "clientServerGroupRemoved",
1838
+ payload: result.event,
1839
+ });
1761
1840
  break;
1762
1841
  case "clientChannelGroupChanged":
1763
- this.#emitServerEvent({ name: "clientChannelGroupChanged", payload: result.event });
1842
+ this.#emitServerEvent({
1843
+ name: "clientChannelGroupChanged",
1844
+ payload: result.event,
1845
+ });
1764
1846
  break;
1765
1847
  case "channelCreated":
1766
1848
  this.#viewStore.applyCreated(result.event);
1767
- this.#emitServerEvent({ name: "channelCreated", payload: result.event });
1849
+ this.#emitServerEvent({
1850
+ name: "channelCreated",
1851
+ payload: result.event,
1852
+ });
1768
1853
  break;
1769
1854
  case "channelEdited":
1770
1855
  this.#viewStore.applyEdited(result.event);
@@ -1784,31 +1869,49 @@ export class Client implements CommandExecutor, AsyncDisposable {
1784
1869
  break;
1785
1870
  }
1786
1871
  case "channelDescriptionChanged":
1787
- this.#emitServerEvent({ name: "channelDescriptionChanged", payload: result.event });
1872
+ this.#emitServerEvent({
1873
+ name: "channelDescriptionChanged",
1874
+ payload: result.event,
1875
+ });
1788
1876
  break;
1789
1877
  case "channelPasswordChanged":
1790
- this.#emitServerEvent({ name: "channelPasswordChanged", payload: result.event });
1878
+ this.#emitServerEvent({
1879
+ name: "channelPasswordChanged",
1880
+ payload: result.event,
1881
+ });
1791
1882
  break;
1792
1883
  case "serverEdited":
1793
1884
  this.#viewStore.patchServer(result.event.changes);
1794
1885
  this.#emitServerEvent({ name: "serverEdited", payload: result.event });
1795
1886
  break;
1796
1887
  case "channelSubscribed":
1797
- this.#emitServerEvent({ name: "channelSubscribed", payload: result.event });
1888
+ this.#emitServerEvent({
1889
+ name: "channelSubscribed",
1890
+ payload: result.event,
1891
+ });
1798
1892
  break;
1799
1893
  case "channelUnsubscribed":
1800
1894
  // The server does not say the clients of the channel left.
1801
1895
  this.#viewStore.dropChannel(result.event.channelID);
1802
- this.#emitServerEvent({ name: "channelUnsubscribed", payload: result.event });
1896
+ this.#emitServerEvent({
1897
+ name: "channelUnsubscribed",
1898
+ payload: result.event,
1899
+ });
1803
1900
  break;
1804
1901
  case "pluginCommand":
1805
1902
  this.#emitServerEvent({ name: "pluginCommand", payload: result.event });
1806
1903
  break;
1807
1904
  case "clientChatComposing":
1808
- this.#emitServerEvent({ name: "clientChatComposing", payload: result.event });
1905
+ this.#emitServerEvent({
1906
+ name: "clientChatComposing",
1907
+ payload: result.event,
1908
+ });
1809
1909
  break;
1810
1910
  case "clientChatClosed":
1811
- this.#emitServerEvent({ name: "clientChatClosed", payload: result.event });
1911
+ this.#emitServerEvent({
1912
+ name: "clientChatClosed",
1913
+ payload: result.event,
1914
+ });
1812
1915
  break;
1813
1916
  case "poked":
1814
1917
  this.#emitServerEvent({ name: "poked", payload: result.event });
@@ -1970,7 +2073,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
1970
2073
  try {
1971
2074
  retry = (options.shouldRetry ?? defaultShouldRetry)(err, attempt);
1972
2075
  } catch (error) {
1973
- this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
2076
+ this.#logger.error("shouldRetry threw; not reconnecting", {
2077
+ error: String(error),
2078
+ });
1974
2079
  retry = false;
1975
2080
  threw = true;
1976
2081
  }
@@ -2030,7 +2135,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
2030
2135
  // Connected, or connecting, by hand during the wait: nothing is left to
2031
2136
  // retry, and an attempt now would only fail with AlreadyConnectedError.
2032
2137
  if (this.#status !== ClientStatus.Disconnected) {
2033
- this.#logger.info("reconnect loop ended: connected by other means", { attempt });
2138
+ this.#logger.info("reconnect loop ended: connected by other means", {
2139
+ attempt,
2140
+ });
2034
2141
  return;
2035
2142
  }
2036
2143
 
@@ -2059,12 +2166,17 @@ export class Client implements CommandExecutor, AsyncDisposable {
2059
2166
  : new TeamspeakError(String(retryErr), { cause: retryErr });
2060
2167
  }
2061
2168
  }
2062
- this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
2169
+ this.#logger.warn("reconnect attempts exhausted", {
2170
+ attempts: policy.maxAttempts,
2171
+ });
2063
2172
  stoppedBy = { cause: err };
2064
2173
  } finally {
2065
2174
  if (stoppedBy !== undefined) {
2066
2175
  const { cause } = stoppedBy;
2067
- this.#callHook(this.#trace?.reconnectStopped, () => ({ attempts: made, cause }));
2176
+ this.#callHook(this.#trace?.reconnectStopped, () => ({
2177
+ attempts: made,
2178
+ cause,
2179
+ }));
2068
2180
  }
2069
2181
  this.#reconnecting = false;
2070
2182
  this.#loopAttempt = 0;
@@ -140,18 +140,26 @@ export class Crypt {
140
140
  this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
141
141
  }
142
142
 
143
+ /**
144
+ * Build the IV from `alpha` and `beta` XORed into `sharedKey` and complete
145
+ * crypto init. The values come from the server, so a length that does not fit
146
+ * is refused rather than padded: alpha must be 10 bytes, beta at least one,
147
+ * and the key as long as the IV, 10 bytes plus beta.
148
+ */
143
149
  setSharedSecret(alpha: Uint8Array, beta: Uint8Array, sharedKey: Uint8Array): void {
150
+ if (
151
+ alpha.length !== IV_ALPHA_SIZE ||
152
+ beta.length === 0 ||
153
+ IV_ALPHA_SIZE + beta.length > sharedKey.length
154
+ ) {
155
+ throw new HandshakeError("alpha, beta and the shared secret do not fit together");
156
+ }
144
157
  this.#ivStruct = new Uint8Array(IV_ALPHA_SIZE + beta.length);
145
158
  for (let i = 0; i < IV_ALPHA_SIZE; i++) {
146
- const a = sharedKey[i];
147
- const b = alpha[i];
148
- this.#ivStruct[i] = ((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
159
+ this.#ivStruct[i] = (sharedKey[i]! ^ alpha[i]!) & 0xff;
149
160
  }
150
161
  for (let i = 0; i < beta.length; i++) {
151
- const a = sharedKey[IV_ALPHA_SIZE + i];
152
- const b = beta[i];
153
- this.#ivStruct[IV_ALPHA_SIZE + i] =
154
- ((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
162
+ this.#ivStruct[IV_ALPHA_SIZE + i] = (sharedKey[IV_ALPHA_SIZE + i]! ^ beta[i]!) & 0xff;
155
163
  }
156
164
 
157
165
  // Keys derive from the IV: any cached before it changed belong to another
@@ -81,9 +81,13 @@ function aesCtr(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array {
81
81
 
82
82
  // ---- AES-CMAC (RFC 4493) ----------------------------------------------------
83
83
 
84
+ /**
85
+ * The CMAC subkeys of each key, derived once: a session uses one key for every
86
+ * packet, and deriving them costs a cipher of its own.
87
+ */
88
+ const subkeyCache = new WeakMap<Uint8Array, [Uint8Array, Uint8Array]>();
89
+
84
90
  function aesEcb(key: Uint8Array, block: Uint8Array): Uint8Array {
85
- // Node.js does not expose ECB directly; simulate via CTR with zero IV and
86
- // a zero-input (CTR of 0-block XOR with input = ECB)
87
91
  const cipher = createCipheriv("aes-128-ecb", Buffer.from(key), null);
88
92
  cipher.setAutoPadding(false);
89
93
  return Buffer.concat([cipher.update(Buffer.from(block)), cipher.final()]);
@@ -110,6 +114,8 @@ const RB = new Uint8Array(EAX_BLOCK_SIZE);
110
114
  RB[EAX_BLOCK_SIZE - 1] = 0x87;
111
115
 
112
116
  function generateSubkeys(key: Uint8Array): [Uint8Array, Uint8Array] {
117
+ const cached = subkeyCache.get(key);
118
+ if (cached !== undefined) return cached;
113
119
  const zero = new Uint8Array(EAX_BLOCK_SIZE);
114
120
  const L = aesEcb(key, zero);
115
121
 
@@ -119,34 +125,33 @@ function generateSubkeys(key: Uint8Array): [Uint8Array, Uint8Array] {
119
125
  let K2 = shiftLeft1(K1);
120
126
  if ((K1[0]! & 0x80) !== 0) K2 = xorBlock(K2, RB);
121
127
 
122
- return [K1, K2];
128
+ const subkeys: [Uint8Array, Uint8Array] = [K1, K2];
129
+ subkeyCache.set(key, subkeys);
130
+ return subkeys;
123
131
  }
124
132
 
133
+ const ZERO_IV = Buffer.alloc(EAX_BLOCK_SIZE);
134
+
135
+ /**
136
+ * AES-CMAC: the last block of AES-CBC with a zero IV over the message, its last
137
+ * block padded and XORed with a subkey. One cipher for the whole message.
138
+ */
125
139
  export function aesCmac(key: Uint8Array, message: Uint8Array): Uint8Array {
126
140
  const [K1, K2] = generateSubkeys(key);
127
141
 
128
142
  const n = Math.max(1, Math.ceil(message.length / EAX_BLOCK_SIZE));
129
143
  const lastBlockComplete = message.length > 0 && message.length % EAX_BLOCK_SIZE === 0;
130
144
 
131
- let X: Uint8Array = new Uint8Array(EAX_BLOCK_SIZE);
132
-
133
- for (let i = 0; i < n - 1; i++) {
134
- const block = message.slice(i * EAX_BLOCK_SIZE, (i + 1) * EAX_BLOCK_SIZE);
135
- X = Uint8Array.from(aesEcb(key, xorBlock(X, block)));
136
- }
137
-
138
- const lastBlock = new Uint8Array(EAX_BLOCK_SIZE);
145
+ const padded = Buffer.alloc(n * EAX_BLOCK_SIZE);
146
+ padded.set(message);
139
147
  const lastStart = (n - 1) * EAX_BLOCK_SIZE;
140
- const lastSlice = message.slice(lastStart);
141
- lastBlock.set(lastSlice);
142
-
143
- let Mn: Uint8Array;
144
- if (lastBlockComplete) {
145
- Mn = xorBlock(lastBlock, K1);
146
- } else {
147
- if (lastSlice.length < EAX_BLOCK_SIZE) lastBlock[lastSlice.length] = 0x80;
148
- Mn = xorBlock(lastBlock, K2);
149
- }
148
+ if (!lastBlockComplete) padded[message.length] = 0x80;
149
+ const subkey = lastBlockComplete ? K1 : K2;
150
+ for (let i = 0; i < EAX_BLOCK_SIZE; i++)
151
+ padded[lastStart + i] = padded[lastStart + i]! ^ subkey[i]!;
150
152
 
151
- return Uint8Array.from(aesEcb(key, xorBlock(X, Mn)));
153
+ const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), ZERO_IV);
154
+ cipher.setAutoPadding(false);
155
+ const out = cipher.update(padded);
156
+ return Uint8Array.from(out.subarray(out.length - EAX_BLOCK_SIZE));
152
157
  }
@@ -195,6 +195,31 @@ export class ConnectionTimeoutError extends TeamspeakError {
195
195
  }
196
196
  }
197
197
 
198
+ /**
199
+ * The server's command stream broke: a command packet stayed missing for
200
+ * seconds while later ones arrived, although the server sends one again until
201
+ * it is acknowledged. The connection is ended rather than carry on without the
202
+ * missing command, which would drop an answer or a notification and could
203
+ * leave the view out of step with the server's; a reconnect starts from a
204
+ * consistent state.
205
+ */
206
+ export class ConnectionStalledError extends TeamspeakError {
207
+ /** {@inheritDoc @honeybbq/teamspeak!TeamspeakError.kind} */
208
+ static override readonly kind: string = "ConnectionStalledError";
209
+ static {
210
+ Object.defineProperty(this.prototype, "name", {
211
+ value: "ConnectionStalledError",
212
+ writable: true,
213
+ configurable: true,
214
+ enumerable: false,
215
+ });
216
+ }
217
+
218
+ constructor() {
219
+ super("connection stalled: a command from the server never arrived");
220
+ }
221
+ }
222
+
198
223
  /**
199
224
  * The server ended this client's session: a kick, a ban, or the server
200
225
  * stopping. The default reconnect policy retries a server that stopped, but
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { buildCommandOrdered } from "../../core/internal.js";
3
3
  import { MAX_RSA_CHALLENGE_LEVEL, type Crypt } from "../crypto/crypt.js";
4
- import { HandshakeError, PuzzleTooHardError } from "../errors.js";
4
+ import { PuzzleTooHardError } from "../errors.js";
5
5
 
6
6
  export const INIT_VERSION = 1566914096; // 3.5.0 [Stable]
7
7
 
@@ -35,7 +35,8 @@ export function puzzleOf(crypt: Crypt): { readonly level: number; readonly durat
35
35
  /**
36
36
  * Handle the TS3INIT1 handshake steps.
37
37
  * Returns the response bytes to send, or null if nothing should be sent.
38
- * Throws a HandshakeError for a step that cannot be answered.
38
+ * A step that cannot be answered is ignored (null); a puzzle out of range
39
+ * throws a PuzzleTooHardError.
39
40
  *
40
41
  * The puzzle step is the only one that takes time, so a challenge not answered
41
42
  * before returns a promise of the response, which gives the event loop back
@@ -56,7 +57,9 @@ export function processInit1(
56
57
  if (data === null || (data.length >= 1 && data[0] === 0x7f)) {
57
58
  return buildInit1StartPacket();
58
59
  }
59
- if (data.length === 0) throw new HandshakeError("empty Init1 packet");
60
+ // Init1 is not authenticated: a step that cannot be answered, an empty one
61
+ // included, is ignored, and the attempt goes on with the real server's.
62
+ if (data.length === 0) return null;
60
63
 
61
64
  switch (data[0]) {
62
65
  case 0:
@@ -38,6 +38,7 @@ export {
38
38
  InvalidIdentityError,
39
39
  RemovedError,
40
40
  ConnectionTimeoutError,
41
+ ConnectionStalledError,
41
42
  } from "./errors.js";
42
43
 
43
44
  // Commands only the voice protocol has.
@@ -1,5 +1,6 @@
1
1
  interface Waiter {
2
2
  readonly resolve: () => void;
3
+ readonly reject: (reason: unknown) => void;
3
4
  readonly signal: AbortSignal | undefined;
4
5
  readonly onAbort: () => void;
5
6
  }
@@ -18,6 +19,8 @@ export class CommandThrottle {
18
19
 
19
20
  #tokens = 5.0;
20
21
  #lastUpdate = performance.now();
22
+ /** Until when nothing is released: the server asked this client to wait. */
23
+ #holdUntil = 0;
21
24
  readonly #waiters: Waiter[] = [];
22
25
  #timer: ReturnType<typeof setTimeout> | undefined;
23
26
 
@@ -29,13 +32,14 @@ export class CommandThrottle {
29
32
  wait(signal?: AbortSignal): Promise<void> {
30
33
  if (signal?.aborted) return Promise.reject(signal.reason);
31
34
  this.#refill();
32
- if (this.#waiters.length === 0 && this.#tokens >= 1.0) {
35
+ if (this.#waiters.length === 0 && this.#tokens >= 1.0 && performance.now() >= this.#holdUntil) {
33
36
  this.#tokens -= 1.0;
34
37
  return Promise.resolve();
35
38
  }
36
39
  return new Promise<void>((resolve, reject) => {
37
40
  const waiter: Waiter = {
38
41
  resolve,
42
+ reject,
39
43
  signal,
40
44
  onAbort: () => {
41
45
  const index = this.#waiters.indexOf(waiter);
@@ -50,9 +54,35 @@ export class CommandThrottle {
50
54
  });
51
55
  }
52
56
 
57
+ /**
58
+ * Release nothing for `ms` milliseconds, then go on at the usual rate with no
59
+ * burst saved up: the server refused a command as flooding and said when to
60
+ * try again, and anything sent sooner is refused too and adds to the debt.
61
+ */
62
+ hold(ms: number): void {
63
+ this.#refill();
64
+ this.#holdUntil = Math.max(this.#holdUntil, performance.now() + ms);
65
+ this.#tokens = 0;
66
+ this.#stopTimer();
67
+ this.#schedule();
68
+ }
69
+
70
+ /**
71
+ * Reject every waiter still in line with `reason`, so none of them is ever
72
+ * released to send. Commands that ask afterwards wait as before.
73
+ */
74
+ rejectAll(reason: unknown): void {
75
+ this.#stopTimer();
76
+ for (const waiter of this.#waiters.splice(0)) {
77
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
78
+ waiter.reject(reason);
79
+ }
80
+ }
81
+
53
82
  #refill(): void {
54
83
  const now = performance.now();
55
- const elapsed = (now - this.#lastUpdate) / 1000;
84
+ // Nothing accrues while held.
85
+ const elapsed = Math.max(0, now - Math.max(this.#lastUpdate, this.#holdUntil)) / 1000;
56
86
  this.#tokens = Math.min(
57
87
  this.#tokens + elapsed * CommandThrottle.TOKEN_RATE,
58
88
  CommandThrottle.TOKEN_MAX,
@@ -63,10 +93,13 @@ export class CommandThrottle {
63
93
  /** Arm the timer for when the next token is due, unless it already is. */
64
94
  #schedule(): void {
65
95
  if (this.#timer !== undefined || this.#waiters.length === 0) return;
66
- const waitMs =
96
+ const forTokens =
67
97
  this.#tokens >= 1.0
68
98
  ? 0
69
99
  : Math.ceil(((1.0 - this.#tokens) / CommandThrottle.TOKEN_RATE) * 1000) + 10;
100
+ const held = Math.ceil(this.#holdUntil - performance.now());
101
+ // Tokens accrue only once the hold is over.
102
+ const waitMs = Math.max(0, held) + forTokens;
70
103
  this.#timer = setTimeout(() => {
71
104
  this.#timer = undefined;
72
105
  this.#release();
@@ -81,6 +114,11 @@ export class CommandThrottle {
81
114
  /** Hand the tokens there are to the waiters at the head of the line. */
82
115
  #release(): void {
83
116
  this.#refill();
117
+ // Woken early, or the hold was extended meanwhile.
118
+ if (performance.now() < this.#holdUntil) {
119
+ this.#schedule();
120
+ return;
121
+ }
84
122
  while (this.#waiters.length > 0 && this.#tokens >= 1.0) {
85
123
  this.#tokens -= 1.0;
86
124
  const waiter = this.#waiters.shift()!;