@honeybbq/teamspeak 1.0.0-beta.20 → 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 (48) 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 +9 -2
  5. package/dist/client/api/files.d.ts.map +1 -1
  6. package/dist/client/api/files.js +15 -5
  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/client.d.ts.map +1 -1
  13. package/dist/client/client.js +5 -12
  14. package/dist/client/client.js.map +1 -1
  15. package/dist/client/transfer.d.ts +14 -0
  16. package/dist/client/transfer.d.ts.map +1 -1
  17. package/dist/client/transfer.js +43 -6
  18. package/dist/client/transfer.js.map +1 -1
  19. package/dist/core/api/files.d.ts +5 -1
  20. package/dist/core/api/files.d.ts.map +1 -1
  21. package/dist/core/api/files.js +5 -1
  22. package/dist/core/api/files.js.map +1 -1
  23. package/dist/core/errors.d.ts +33 -3
  24. package/dist/core/errors.d.ts.map +1 -1
  25. package/dist/core/errors.js +41 -5
  26. package/dist/core/errors.js.map +1 -1
  27. package/dist/core/internal.d.ts +1 -1
  28. package/dist/core/internal.d.ts.map +1 -1
  29. package/dist/core/internal.js +1 -1
  30. package/dist/core/internal.js.map +1 -1
  31. package/dist/query/client.d.ts +4 -0
  32. package/dist/query/client.d.ts.map +1 -1
  33. package/dist/query/client.js +82 -15
  34. package/dist/query/client.js.map +1 -1
  35. package/dist/query/options.d.ts +4 -0
  36. package/dist/query/options.d.ts.map +1 -1
  37. package/dist/query/options.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/client/api/avatars.ts +15 -2
  40. package/src/client/api/files.ts +21 -5
  41. package/src/client/api/upload-checks.ts +96 -0
  42. package/src/client/client.ts +6 -12
  43. package/src/client/transfer.ts +45 -9
  44. package/src/core/api/files.ts +5 -1
  45. package/src/core/errors.ts +41 -5
  46. package/src/core/internal.ts +7 -1
  47. package/src/query/client.ts +84 -14
  48. package/src/query/options.ts +4 -0
@@ -22,6 +22,7 @@ import type {
22
22
  } from "@honeybbq/teamspeak";
23
23
  import { FileTransferError } from "../errors.js";
24
24
  import { downloadFileData, uploadFileData } from "../transfer.js";
25
+ import { verifyStored, withDialCleanup } from "./upload-checks.js";
25
26
  import type { FileTransferOptions } from "../transfer.js";
26
27
 
27
28
  /**
@@ -173,8 +174,15 @@ function skipBytes(count: number): Transform {
173
174
  *
174
175
  * `data` is the bytes, or a `Readable` with `options.size` the number of bytes
175
176
  * it will give. It resolves once the server has closed the transfer
176
- * connection, which it does when it has the file; a stream that gives fewer or
177
- * more bytes than `size` fails the call, and the connection is closed.
177
+ * connection and, by `ftgetfileinfo` (one more command), holds a file of
178
+ * exactly that size: the server does not say whether it stored a file, so a
179
+ * close alone could be a transfer someone stopped. A size that differs, or a
180
+ * file that is not there, fails the call with a {@link FileTransferError}; a
181
+ * client with no browse power cannot ask (2568) and resolves on the close. A
182
+ * stream that gives fewer or more bytes than `size` fails the call, and the
183
+ * connection is closed. When the connection to the file transfer port is
184
+ * refused or times out, the negotiated transfer is stopped on the server (else
185
+ * it lingers in `ftlist` for about 10 seconds), as it is for `downloadFile`.
178
186
  *
179
187
  * With `resume`, `data` is still the whole file: the server says how much it
180
188
  * already holds from an interrupted upload (a file that is not there is
@@ -213,7 +221,10 @@ export async function uploadFile(
213
221
  if (data instanceof Uint8Array) source = Readable.from([data.subarray(skip)]);
214
222
  else if (skip === 0) source = data;
215
223
  else source = pipeline(data, skipBytes(skip), () => {});
216
- await route.upload(info, source, transferOptions(options));
224
+ await withDialCleanup(client, info.serverFileTransferID, () =>
225
+ route.upload(info, source, transferOptions(options)),
226
+ );
227
+ await verifyStored(client, channelID, path, size, options);
217
228
  }
218
229
 
219
230
  /**
@@ -266,7 +277,10 @@ export async function downloadFile(
266
277
  timeoutMs: options.timeoutMs,
267
278
  });
268
279
  if (options.dest !== undefined) {
269
- await route.download(info, options.dest, transferOptions(options));
280
+ const dest = options.dest;
281
+ await withDialCleanup(client, info.serverFileTransferID, () =>
282
+ route.download(info, dest, transferOptions(options)),
283
+ );
270
284
  return;
271
285
  }
272
286
  const wanted = info.size - info.seekPosition;
@@ -286,7 +300,9 @@ export async function downloadFile(
286
300
  done();
287
301
  },
288
302
  });
289
- await route.download(info, sink, transferOptions(options));
303
+ await withDialCleanup(client, info.serverFileTransferID, () =>
304
+ route.download(info, sink, transferOptions(options)),
305
+ );
290
306
  return Buffer.concat(chunks);
291
307
  }
292
308
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What an upload does around its data phase: stop a transfer whose connection
3
+ * was never made, and check what the server stored once it closed the
4
+ * connection. Shared by the file and avatar helpers; not exported from the
5
+ * package.
6
+ */
7
+
8
+ import { ErrorCode, ServerError, getFileInfo, stopFileTransfer } from "@honeybbq/teamspeak";
9
+ import type { ChannelID, CommandExecutor, FileUploadInfo } from "@honeybbq/teamspeak";
10
+ import { FileTransferError } from "../errors.js";
11
+ import { failedToConnect } from "../transfer.js";
12
+
13
+ /**
14
+ * The server drops a transfer nobody dialled after about 10 seconds (measured
15
+ * on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: it is gone from `ftlist` at
16
+ * 10.05 s, and the voice client hears 2059). `ftstop` of a transfer the server
17
+ * no longer has crashes TeamSpeak 6, so a stop is only sent well inside that.
18
+ */
19
+ const STOP_WITHIN_MS = 5_000;
20
+
21
+ /**
22
+ * Runs the data phase of a transfer the server has negotiated. When it fails
23
+ * because the connection to the file transfer port was refused or timed out,
24
+ * nothing has reached the server, and the transfer would sit in its list until
25
+ * it expires: stop it, as a refused `maxSize` download does. The stop is best
26
+ * effort and never replaces the dial's error. Not sent once the connection
27
+ * opened (the key may be written and the server may have dropped the transfer),
28
+ * nor after `STOP_WITHIN_MS`.
29
+ */
30
+ export async function withDialCleanup(
31
+ client: CommandExecutor,
32
+ serverFileTransferID: FileUploadInfo["serverFileTransferID"],
33
+ move: () => Promise<void>,
34
+ ): Promise<void> {
35
+ const negotiatedAt = performance.now();
36
+ try {
37
+ await move();
38
+ } catch (error) {
39
+ if (failedToConnect(error) && performance.now() - negotiatedAt < STOP_WITHIN_MS) {
40
+ await stopFileTransfer(client, serverFileTransferID).catch(() => {});
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Checks that the server holds the whole file after an upload's connection
48
+ * closed. Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: a completed
49
+ * upload gets no status from the server, so a close after the last byte looks
50
+ * the same whether the file was stored or the transfer was stopped (the voice
51
+ * protocol reports that, 2066, only after the close), and `ftgetfileinfo`
52
+ * already answers the final size 6 to 45 ms after the close, in every case.
53
+ * Permissions are checked at the negotiation only, so no refusal comes later.
54
+ *
55
+ * A client without browse power gets 2568 for `ftgetfileinfo`: the file cannot
56
+ * be checked, and the upload resolves on the close. Any other failure of the
57
+ * command is thrown as it is.
58
+ */
59
+ export async function verifyStored(
60
+ client: CommandExecutor,
61
+ channelID: ChannelID,
62
+ path: string,
63
+ size: bigint,
64
+ options: {
65
+ readonly password?: string | undefined;
66
+ readonly signal?: AbortSignal | undefined;
67
+ readonly timeoutMs?: number | undefined;
68
+ },
69
+ ): Promise<void> {
70
+ let stored: bigint;
71
+ try {
72
+ stored = (
73
+ await getFileInfo(client, channelID, path, {
74
+ password: options.password,
75
+ signal: options.signal,
76
+ timeoutMs: options.timeoutMs,
77
+ })
78
+ ).size;
79
+ } catch (error) {
80
+ if (error instanceof ServerError) {
81
+ if (error.code === ErrorCode.PermissionsClientInsufficient) return;
82
+ // 2051 on TeamSpeak 6, 2052 on TeamSpeak 3 for a file that is not there.
83
+ if (error.code === ErrorCode.FileNotFound || error.code === ErrorCode.FileIOError) {
84
+ throw new FileTransferError(`the server closed the upload but has no file ${path}`, {
85
+ cause: error,
86
+ });
87
+ }
88
+ }
89
+ throw error;
90
+ }
91
+ if (stored !== size) {
92
+ throw new FileTransferError(
93
+ `the server closed the upload but holds ${stored} of the ${size} bytes of ${path}`,
94
+ );
95
+ }
96
+ }
@@ -50,6 +50,8 @@ import {
50
50
  buildCommand,
51
51
  buildCommandChain,
52
52
  encodeRequest,
53
+ floodHoldMs,
54
+ FLOOD_RETRIES,
53
55
  floodRetryDelayMs,
54
56
  parseServerError,
55
57
  resolveBackoffPolicy,
@@ -193,15 +195,6 @@ export function clientInternals(client: Client): ClientInternals {
193
195
  return internalsOf(client);
194
196
  }
195
197
 
196
- /**
197
- * How many times a command refused as flooding is sent again after the pause
198
- * the server asked for. The first resend is usually refused once more with a
199
- * shorter wait, and the second goes through (measured on TeamSpeak 3 and 6).
200
- */
201
- const FLOOD_RETRIES = 3;
202
- /** Added to the server's wait, which counts down to the millisecond. */
203
- const FLOOD_HOLD_MARGIN_MS = 10;
204
-
205
198
  /** How long a command waits for its answer unless told otherwise. */
206
199
  const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
207
200
 
@@ -965,17 +958,18 @@ export class Client implements CommandExecutor, AsyncDisposable {
965
958
  // afford: nothing is sent until then, and the command, which the
966
959
  // server did not run, goes out again.
967
960
  const wait = result.err ? floodRetryDelayMs(result.err) : undefined;
961
+ const hold = wait === undefined ? undefined : floodHoldMs(wait);
968
962
  if (
969
- wait !== undefined &&
963
+ hold !== undefined &&
970
964
  !goodbye &&
971
965
  refused < FLOOD_RETRIES &&
972
- wait < deadline - performance.now()
966
+ hold < deadline - performance.now()
973
967
  ) {
974
968
  this.#logger.debug("the server asked for a pause", {
975
969
  command: parsed?.name,
976
970
  waitMs: wait,
977
971
  });
978
- this.#throttle.hold(wait + FLOOD_HOLD_MARGIN_MS);
972
+ this.#throttle.hold(hold);
979
973
  this.#cmdTrack.unregister(rc);
980
974
  [rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
981
975
  continue;
@@ -28,6 +28,22 @@ const PROGRESS_STEP_BYTES = 64 * 1024;
28
28
  /** The file transfer ports a connection can be made to. */
29
29
  const MAX_PORT = 65535;
30
30
 
31
+ /**
32
+ * The errors of dials that failed before the connection opened. Nothing has
33
+ * reached the server then: not the key, and no byte of the file.
34
+ */
35
+ const failedBeforeConnect = new WeakSet();
36
+
37
+ /**
38
+ * Whether `error` is a dial that never got a connection: refused, unreachable,
39
+ * or timed out while connecting. Not one the caller's signal or the idle limit
40
+ * ended, nor one after the connection opened, where the key may have been
41
+ * written and the server may hold or have dropped the transfer.
42
+ */
43
+ export function failedToConnect(error: unknown): boolean {
44
+ return typeof error === "object" && error !== null && failedBeforeConnect.has(error);
45
+ }
46
+
31
47
  /** Where a failed transfer failed. */
32
48
  export type FileTransferStage = "dial" | "transfer";
33
49
 
@@ -239,8 +255,10 @@ async function dial(
239
255
  // report: a stream's unhandled `error` event takes the process down.
240
256
  const swallow = (): void => {};
241
257
  socket.on("error", swallow);
258
+ let open = false;
242
259
  try {
243
260
  await abortable(connected(socket), dialSignal);
261
+ open = true;
244
262
  idle.touch();
245
263
  await abortable(
246
264
  new Promise<void>((resolve, reject) => {
@@ -260,7 +278,11 @@ async function dial(
260
278
  } catch (error) {
261
279
  socket.destroy();
262
280
  if (timeout.aborted && error === timeout.reason) {
263
- throw new FileTransferError("file transfer connection timed out", { cause: error });
281
+ const timedOut = new FileTransferError("file transfer connection timed out", {
282
+ cause: error,
283
+ });
284
+ if (!open) failedBeforeConnect.add(timedOut);
285
+ throw timedOut;
264
286
  }
265
287
  throw error;
266
288
  } finally {
@@ -393,12 +415,13 @@ function connected(socket: Duplex): Promise<void> {
393
415
  else reject(error);
394
416
  };
395
417
  const onConnect = (): void => done();
396
- const onError = (error: Error): void =>
397
- done(
398
- new FileTransferError(`file transfer connection failed: ${error.message}`, {
399
- cause: error,
400
- }),
401
- );
418
+ const onError = (error: Error): void => {
419
+ const failure = new FileTransferError(`file transfer connection failed: ${error.message}`, {
420
+ cause: error,
421
+ });
422
+ failedBeforeConnect.add(failure);
423
+ done(failure);
424
+ };
402
425
  socket.once("connect", onConnect);
403
426
  socket.once("ready", onConnect);
404
427
  socket.once("error", onError);
@@ -444,13 +467,19 @@ function intoStream(dest: Writable): Writable {
444
467
  * server says so, not when the last byte has left.
445
468
  */
446
469
  function intoSocket(socket: Duplex): Writable {
470
+ // A write after the server closed the connection fails with the socket's own
471
+ // error (EPIPE, or "ended by the other party"): the transfer's failure.
472
+ const failure = (error: Error): FileTransferError =>
473
+ new FileTransferError(`file transfer connection failed: ${error.message}`, {
474
+ cause: error,
475
+ });
447
476
  const sink = new Writable({
448
477
  write(chunk: Buffer, _encoding, callback) {
449
- socket.write(chunk, callback);
478
+ socket.write(chunk, (error) => callback(error ? failure(error) : null));
450
479
  },
451
480
  });
452
481
  socket.on("error", (error: Error) => {
453
- sink.destroy(error);
482
+ sink.destroy(failure(error));
454
483
  });
455
484
  return sink;
456
485
  }
@@ -487,6 +516,13 @@ function serverClosed(socket: Duplex, signal: AbortSignal): Promise<void> {
487
516
  * and at once after the declared size when more is sent. A connection that
488
517
  * closes before every byte is written, or that stays open for the idle limit,
489
518
  * fails the transfer.
519
+ *
520
+ * A close after the last byte is not proof that the file is stored: the server
521
+ * sends nothing on the transfer connection, and over the voice protocol its
522
+ * `notifystatusfiletransfer` comes only for a failure (2066 canceled, 2063
523
+ * lost connection, 2052 more than declared), after the close, never for
524
+ * success; over ServerQuery nothing comes. `uploadFile` therefore asks the
525
+ * server for the file's size with `ftgetfileinfo` before it resolves.
490
526
  */
491
527
  export async function uploadFileData(
492
528
  host: string,
@@ -296,7 +296,11 @@ export interface FileTransferStopOptions extends CommandOptions {
296
296
  * Stop only your own transfers: TeamSpeak 6 6.0.0-beta13.1 crashes on a
297
297
  * ServerQuery `ftstop` for a transfer that is not the connection's own (one
298
298
  * that does not exist, or another client's), and nothing here guards against
299
- * it. See "Server behaviour the SDK works around" in `docs/serverquery-responses.md`.
299
+ * it. A transfer of your own is gone once it has finished, or when nobody
300
+ * connected to it within about 10 seconds of its being negotiated (measured on
301
+ * TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0), so stopping it then crashes
302
+ * TeamSpeak 6 too. See "Server behaviour the SDK works around" in
303
+ * `docs/serverquery-responses.md`.
300
304
  */
301
305
  export async function stopFileTransfer(
302
306
  client: CommandExecutor,
@@ -251,9 +251,29 @@ export function parseErrorCode(raw: string | undefined): number {
251
251
  }
252
252
 
253
253
  /**
254
- * How long the server asks a client it refused as flooding to wait: the
255
- * `retry in 1985ms` of the extra message of error 524, in milliseconds. Undefined
256
- * for any other error, or one that names no wait.
254
+ * How many times a command refused as flooding is sent again after the pause
255
+ * the server asked for. On the voice protocol the first resend is usually
256
+ * refused once more with a shorter wait, and the second goes through (measured
257
+ * on TeamSpeak 3 and 6); on ServerQuery a refusal can be answered with a
258
+ * longer wait, which the caller's timeout then decides whether to sit out.
259
+ */
260
+ export const FLOOD_RETRIES = 3;
261
+ /**
262
+ * How long to pause for a refusal that asked for `waitMs`. The wait is a floor:
263
+ * on the voice protocol a resend after exactly the wait is refused again, and
264
+ * one after half as long again goes through; on ServerQuery a resend after
265
+ * exactly 1 second was refused 2 times in 5, and one after 2.2 seconds went
266
+ * through 5 times in 5 (TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0). Every
267
+ * refusal costs a retry and lengthens the server's pause, so the pause is
268
+ * twice the wait.
269
+ */
270
+ export function floodHoldMs(waitMs: number): number {
271
+ return waitMs * 2;
272
+ }
273
+
274
+ /**
275
+ * How long the server asks a client it refused as flooding to wait, in
276
+ * milliseconds. Undefined for any other error, or one that names no wait.
257
277
  *
258
278
  * Measured on TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0: a voice client whose
259
279
  * antiflood points pass `virtualserver_antiflood_points_needed_command_block`
@@ -261,12 +281,28 @@ export function parseErrorCode(raw: string | undefined): number {
261
281
  * for each command, and the command is not run. The wait is a floor, not a
262
282
  * promise: a command sent as the wait ends is refused again with a shorter one,
263
283
  * and the second try went through in every measurement.
284
+ *
285
+ * A ServerQuery client (raw TCP and SSH; over WebQuery the server closes the
286
+ * connection and says nothing) that sends faster than
287
+ * `serverinstance_serverquery_flood_commands` per `_flood_time` gets the same
288
+ * error with the wait in whole seconds,
289
+ * `extra_msg=please\swait\s1\sseconds`, and the command is not run. The first
290
+ * refusals say 1; later ones say the instance's
291
+ * `serverinstance_serverquery_ban_time`, as the address is banned for that long
292
+ * (new connections are dropped meanwhile). Asking again every half second kept
293
+ * the ban going for as long as it was kept up (9 seconds against a ban time of
294
+ * 4), while waiting out the pause and asking once got through. The wait is a
295
+ * floor here too: about one resend in three after exactly the wait was refused
296
+ * again, with the ban time.
264
297
  */
265
298
  export function floodRetryDelayMs(error: Error): number | undefined {
266
299
  if (!(error instanceof ServerError) || error.code !== ErrorCode.ClientIsFlooding)
267
300
  return undefined;
268
- const match = /^retry in (\d{1,9})ms$/.exec(error.extraMessage ?? "");
269
- return match === null ? undefined : Number(match[1]);
301
+ const extra = error.extraMessage ?? "";
302
+ const millis = /^retry in (\d{1,9})ms$/.exec(extra);
303
+ if (millis !== null) return Number(millis[1]);
304
+ const seconds = /^please wait (\d{1,9}) seconds$/.exec(extra);
305
+ return seconds === null ? undefined : Number(seconds[1]) * 1000;
270
306
  }
271
307
 
272
308
  /**
@@ -20,6 +20,12 @@ export type { NotificationResult } from "./notifications.js";
20
20
  export { parseIconID } from "./command/ids.js";
21
21
  export { responseSpec } from "./command/responses.js";
22
22
  export type { ResponseSpec } from "./command/responses.js";
23
- export { floodRetryDelayMs, parseErrorCode, parseServerError } from "./errors.js";
23
+ export {
24
+ floodHoldMs,
25
+ FLOOD_RETRIES,
26
+ floodRetryDelayMs,
27
+ parseErrorCode,
28
+ parseServerError,
29
+ } from "./errors.js";
24
30
  export { splitCommandRows, parseUint64, parseUint16, parseInt10, sleep } from "./helpers.js";
25
31
  export { jitteredDelay, resolveBackoffPolicy } from "./reconnect.js";
@@ -29,6 +29,9 @@ import {
29
29
  buildCommandChain,
30
30
  buildCommandOrdered,
31
31
  encodeRequest,
32
+ floodHoldMs,
33
+ FLOOD_RETRIES,
34
+ floodRetryDelayMs,
32
35
  handleNotification,
33
36
  parseCommand,
34
37
  parseServerError,
@@ -159,6 +162,8 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
159
162
  #lastSent = 0;
160
163
  #keepAlive: ReturnType<typeof setTimeout> | undefined;
161
164
  #queue: Promise<unknown> = Promise.resolve();
165
+ /** Connected with no `server`, so the `events` are registered by the first `use()`. */
166
+ #eventsPending = false;
162
167
  #closed = true;
163
168
  /**
164
169
  * Bumped by `disconnect()`. A connection attempt that was in flight compares
@@ -422,7 +427,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
422
427
  transport.onLine = (line) => this.#handleLine(line);
423
428
  transport.onClose = (error) => this.#handleClose(error);
424
429
 
425
- const { transport: resolvedTransport, server, nickname, eventCategories } = this.#options;
430
+ const { transport: resolvedTransport, server, nickname } = this.#options;
426
431
  const login = resolvedTransport.kind === "tcp" ? resolvedTransport.login : undefined;
427
432
  if (login !== undefined) {
428
433
  await this.login(login.name, login.password, { signal }).catch((error: unknown) => {
@@ -442,19 +447,35 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
442
447
  // Like the version, a failure here fails the connect: a client that cannot
443
448
  // tell which one it is would miss its own kick without saying so.
444
449
  await this.#learnSelf(signal);
450
+ // Notifications and the roster belong to a selected server. Measured on
451
+ // TeamSpeak 3 3.13.8 and TeamSpeak 6 6.0.0 (raw TCP and SSH): with none
452
+ // selected `version`, `whoami`, `instanceinfo` and `serverlist` answer,
453
+ // while `servernotifyregister`, `clientlist` and `clientupdate` are refused
454
+ // with 1024 (invalid serverID). So a client with no `server` connects for
455
+ // the instance-level commands, and does this on its first `use()`.
456
+ this.#eventsPending = false;
445
457
  if (this.#notifications) {
446
- for (const category of eventCategories) {
447
- // The channel category takes the channel to watch, or 0 for all of them;
448
- // the others take none, and the server refuses one they do not know.
449
- await this.#register(category, category === "channel" ? 0 : undefined, { signal });
450
- }
451
- // Only worth listing when there is something to fill: the groups and the
452
- // invoker of a message this client would otherwise not know.
453
- if (eventCategories.length > 0) await this.#learnClients(signal);
458
+ if (server === undefined) this.#eventsPending = true;
459
+ else await this.#registerEvents(signal);
454
460
  }
455
461
  this.#startKeepAlive(transport);
456
462
  }
457
463
 
464
+ /**
465
+ * Register the configured categories and list the clients, once a server is
466
+ * selected.
467
+ */
468
+ async #registerEvents(signal: AbortSignal | undefined): Promise<void> {
469
+ for (const category of this.#options.eventCategories) {
470
+ // The channel category takes the channel to watch, or 0 for all of them;
471
+ // the others take none, and the server refuses one they do not know.
472
+ await this.#register(category, category === "channel" ? 0 : undefined, { signal });
473
+ }
474
+ // Only worth listing when there is something to fill: the groups and the
475
+ // invoker of a message this client would otherwise not know.
476
+ if (this.#options.eventCategories.length > 0) await this.#learnClients(signal);
477
+ }
478
+
458
479
  /**
459
480
  * Keep a raw TCP or SSH session from idling out: the server closes one that
460
481
  * has sent no command for 300 seconds (see `QueryClientOptions.keepAliveMs`),
@@ -613,6 +634,10 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
613
634
  * and the notifications registered are gone, so `use` again and
614
635
  * {@link QueryClient.registerNotifications} again.
615
636
  *
637
+ * A client connected with no `server` has registered no `events` yet, as
638
+ * the server refuses that with none selected: the first successful `use`
639
+ * registers them.
640
+ *
616
641
  * A reconnect starts over from the options the client was made with: it logs
617
642
  * in, selects `server` and registers `events` again, and does not repeat a
618
643
  * `use` made by hand.
@@ -626,7 +651,10 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
626
651
  // none has no use for.
627
652
  if (!this.#notifications) return;
628
653
  await this.#learnSelf(options.signal);
629
- if (this.#options.eventCategories.length > 0) await this.#learnClients(options.signal);
654
+ if (this.#eventsPending) {
655
+ await this.#registerEvents(options.signal);
656
+ this.#eventsPending = false;
657
+ } else if (this.#options.eventCategories.length > 0) await this.#learnClients(options.signal);
630
658
  }
631
659
 
632
660
  /** The `use` command, and what it does to what the client knows. */
@@ -939,7 +967,49 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
939
967
  }
940
968
  }
941
969
 
970
+ /**
971
+ * Run a command, and run it again when the server refuses it as flooding
972
+ * with a time to wait that the command can afford. The server did not run a
973
+ * refused command, and the next command waits behind this one in the queue,
974
+ * so nothing is sent while the pause lasts.
975
+ *
976
+ * Measured on TeamSpeak 3 3.13.8 (raw TCP) and TeamSpeak 6 6.0.0 (SSH): the
977
+ * refusal is `error id=524 ... extra_msg=please\swait\s1\sseconds`, the
978
+ * connection stays open, and sending again before the pause is over only
979
+ * prolongs it. WebQuery has no such answer; see {@link floodRetryDelayMs}.
980
+ */
942
981
  async #execNow(line: string, options: CommandOptions): Promise<Array<Record<string, string>>> {
982
+ const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
983
+ const deadline = performance.now() + timeoutMs;
984
+ for (let refused = 0; ; refused++) {
985
+ try {
986
+ return await this.#execOnce(line, {
987
+ ...options,
988
+ timeoutMs: Math.max(deadline - performance.now(), 0),
989
+ });
990
+ } catch (error) {
991
+ const wait = error instanceof Error ? floodRetryDelayMs(error) : undefined;
992
+ const hold = wait === undefined ? undefined : floodHoldMs(wait);
993
+ if (
994
+ wait === undefined ||
995
+ hold === undefined ||
996
+ refused >= FLOOD_RETRIES ||
997
+ hold >= deadline - performance.now() ||
998
+ this.#closed
999
+ ) {
1000
+ throw error;
1001
+ }
1002
+ this.#logger.debug("the server asked for a pause", {
1003
+ command: parseCommand(line)?.name,
1004
+ waitMs: wait,
1005
+ });
1006
+ await sleep(hold, signal);
1007
+ signal?.throwIfAborted();
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ async #execOnce(line: string, options: CommandOptions): Promise<Array<Record<string, string>>> {
943
1013
  const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
944
1014
  signal?.throwIfAborted();
945
1015
  if (this.#closed || this.#transport === null) throw new NotConnectedError();
@@ -1427,16 +1497,16 @@ function serverIDOf(server: ResolvedQueryOptions["server"]): ServerID | number |
1427
1497
  }
1428
1498
 
1429
1499
  /** Sleep, or wake early once `signal` aborts. Resolves either way, never rejects. */
1430
- function sleep(ms: number, signal: AbortSignal): Promise<void> {
1431
- if (ms <= 0 || signal.aborted) return Promise.resolve();
1500
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1501
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
1432
1502
  return new Promise((resolve) => {
1433
1503
  const done = (): void => {
1434
1504
  clearTimeout(timer);
1435
- signal.removeEventListener("abort", done);
1505
+ signal?.removeEventListener("abort", done);
1436
1506
  resolve();
1437
1507
  };
1438
1508
  const timer = setTimeout(done, ms);
1439
- signal.addEventListener("abort", done, { once: true });
1509
+ signal?.addEventListener("abort", done, { once: true });
1440
1510
  });
1441
1511
  }
1442
1512
 
@@ -126,6 +126,10 @@ export interface QueryClientOptions {
126
126
  * WebQuery the transport answers `use` itself, by addressing that server in
127
127
  * its requests. Unset leaves the instance selected on raw TCP, where the
128
128
  * server's own default applies.
129
+ *
130
+ * Without it the client connects with no server selected, which serves the
131
+ * instance-level commands (`instanceinfo`, `serverlist`, `use`). The events
132
+ * need a server, so they are registered by the first {@link QueryClient.use}.
129
133
  */
130
134
  readonly server?: { readonly id: ServerID | number } | { readonly port: number } | undefined;
131
135
  /** The query client's nickname on the server (`client_nickname` of `use`). */