@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
@@ -15,20 +15,20 @@
15
15
 
16
16
  /** How long to wait before each attempt. */
17
17
  export interface BackoffPolicy {
18
- /** Delay before the first retry, in milliseconds. */
18
+ /** Delay before the first retry, in milliseconds; above 0. */
19
19
  readonly initialDelayMs: number;
20
- /** Ceiling for the delay; it never grows past this. */
20
+ /** Ceiling for the delay; it never grows past this. Above 0, and not below `initialDelayMs`. */
21
21
  readonly maxDelayMs: number;
22
- /** Multiplier applied after each failed attempt. */
22
+ /** Multiplier applied after each failed attempt; 1 or more. */
23
23
  readonly factor: number;
24
24
  /**
25
- * Fraction of the delay to randomise, 0 to 1.
25
+ * Fraction of the delay to randomise, up to 1; 0 or below turns it off.
26
26
  *
27
27
  * A fleet of bots that drop together would otherwise retry in lockstep and
28
28
  * hit the server in a burst. Jitter spreads them out.
29
29
  */
30
30
  readonly jitter: number;
31
- /** Give up after this many consecutive failures. 0 means never give up. */
31
+ /** Give up after this many consecutive failures. 0 means never give up; a whole number. */
32
32
  readonly maxAttempts: number;
33
33
  }
34
34
 
@@ -88,11 +88,63 @@ export function jitteredDelay(
88
88
  return Math.round(Math.max(0, Math.min(delay, policy.maxDelayMs)));
89
89
  }
90
90
 
91
+ /**
92
+ * The policy `options` asks for, with the defaults filled in. A value the
93
+ * caller gave that cannot work is a RangeError, thrown when the client is made
94
+ * rather than when the loop first runs: a delay of 0 or a NaN factor would
95
+ * retry in a tight loop, and the flood ban that follows outlasts the outage.
96
+ *
97
+ * Accepted: delays above 0 (an unset `maxDelayMs` is raised to `initialDelayMs`
98
+ * when that is larger than the default; one given below `initialDelayMs` is
99
+ * refused), a factor of at least 1, a jitter of at most 1 (0 or below turns it
100
+ * off), and a whole `maxAttempts` of 0 or more.
101
+ */
91
102
  export function resolveBackoffPolicy(options: ReconnectOptions): BackoffPolicy {
92
103
  const policy: Record<string, number> = { ...defaultBackoffPolicy };
93
104
  // An explicit undefined keeps the default, as an unset field does.
94
- for (const [key, value] of Object.entries(options.backoff ?? {})) {
95
- if (value !== undefined) policy[key] = value;
105
+ const given = Object.entries(options.backoff ?? {}).filter(([, value]) => value !== undefined);
106
+ for (const [key, value] of given) policy[key] = value as number;
107
+ const resolved = policy as unknown as BackoffPolicy;
108
+ const set = (key: keyof BackoffPolicy): boolean => given.some(([k]) => k === key);
109
+
110
+ requireNumber(
111
+ "initialDelayMs",
112
+ resolved.initialDelayMs,
113
+ "a number of milliseconds above 0",
114
+ (v) => v > 0,
115
+ );
116
+ requireNumber(
117
+ "maxDelayMs",
118
+ resolved.maxDelayMs,
119
+ "a number of milliseconds above 0",
120
+ (v) => v > 0,
121
+ );
122
+ requireNumber("factor", resolved.factor, "a number from 1 up", (v) => v >= 1);
123
+ requireNumber("jitter", resolved.jitter, "a number up to 1", (v) => v <= 1);
124
+ requireNumber(
125
+ "maxAttempts",
126
+ resolved.maxAttempts,
127
+ "a whole number, 0 or more",
128
+ (v) => Number.isInteger(v) && v >= 0,
129
+ );
130
+ if (resolved.maxDelayMs < resolved.initialDelayMs) {
131
+ if (set("maxDelayMs")) {
132
+ throw new RangeError(
133
+ `maxDelayMs (${resolved.maxDelayMs}) is below initialDelayMs (${resolved.initialDelayMs})`,
134
+ );
135
+ }
136
+ return { ...resolved, maxDelayMs: resolved.initialDelayMs };
137
+ }
138
+ return resolved;
139
+ }
140
+
141
+ function requireNumber(
142
+ name: string,
143
+ value: unknown,
144
+ expected: string,
145
+ ok: (value: number) => boolean,
146
+ ): void {
147
+ if (typeof value !== "number" || !Number.isFinite(value) || !ok(value)) {
148
+ throw new RangeError(`the backoff's ${name} is ${expected}, not ${String(value)}`);
96
149
  }
97
- return policy as unknown as BackoffPolicy;
98
150
  }
package/src/core/types.ts CHANGED
@@ -77,40 +77,39 @@ export const consoleLogger: Logger = Object.freeze({
77
77
  export interface FileUploadInfo {
78
78
  /** The key that identifies the transfer on the file transfer connection. */
79
79
  readonly fileTransferKey: string;
80
- /** Where to resume from, in bytes; 0 for a fresh upload. */
80
+ /** The file's whole size in bytes, as the negotiation declared it. */
81
+ readonly size: bigint;
82
+ /**
83
+ * Where the upload continues from, in bytes: 0 for a fresh upload, and with
84
+ * `resume` the size of the partial file the server holds. The data to send
85
+ * is the file from here on.
86
+ */
81
87
  readonly seekPosition: bigint;
82
88
  /** The client's id for the transfer. */
83
89
  readonly clientFileTransferID: ClientFileTransferID;
84
90
  /** The server's id for the transfer. */
85
91
  readonly serverFileTransferID: ServerFileTransferID;
86
- /** The file transfer port to dial. */
92
+ /** The file transfer port to dial, on the host the connection uses. */
87
93
  readonly port: number;
88
- /**
89
- * The address to dial instead of the one the connection uses (`ip`): the
90
- * server sends it when it finds the file transfer unreachable at the address
91
- * of the command connection, such as behind NAT. Undefined when it sends
92
- * none, which is every answer measured on TS3 and TS6.
93
- */
94
- readonly ip?: string | undefined;
95
94
  }
96
95
 
97
96
  /** A negotiated download, as `initFileDownload` returns it: what to dial and receive. */
98
97
  export interface FileDownloadInfo {
99
98
  /** The key that identifies the transfer on the file transfer connection. */
100
99
  readonly fileTransferKey: string;
101
- /** How many bytes the server will send. */
100
+ /** The file's whole size in bytes, whatever `seekPosition` is. */
102
101
  readonly size: bigint;
102
+ /**
103
+ * Where the download starts from, in bytes, as it was asked for: the server
104
+ * sends the file from here to its end, `size` less this many bytes.
105
+ */
106
+ readonly seekPosition: bigint;
103
107
  /** The client's id for the transfer. */
104
108
  readonly clientFileTransferID: ClientFileTransferID;
105
109
  /** The server's id for the transfer. */
106
110
  readonly serverFileTransferID: ServerFileTransferID;
107
- /** The file transfer port to dial. */
111
+ /** The file transfer port to dial, on the host the connection uses. */
108
112
  readonly port: number;
109
- /**
110
- * The address to dial instead of the one the connection uses (`ip`); see
111
- * {@link FileUploadInfo.ip}.
112
- */
113
- readonly ip?: string | undefined;
114
113
  }
115
114
 
116
115
  /** Removes a listener; calling it again does nothing. Also a `Disposable`, for `using`. */
@@ -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,
@@ -47,7 +50,7 @@ import {
47
50
  type QueryEventCategory,
48
51
  } from "./options.js";
49
52
  import type { ResolvedQueryOptions, ResolvedTransport } from "./options.js";
50
- import { defaultShouldRetry } from "./reconnect.js";
53
+ import { defaultShouldRetry, noteConnectFailure } from "./reconnect.js";
51
54
 
52
55
  export type {
53
56
  QueryClientOptions,
@@ -155,7 +158,12 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
155
158
  * rather than taken for the next command's.
156
159
  */
157
160
  #abandoned = 0;
161
+ /** When a command was last sent, which is what the server counts idleness from. */
162
+ #lastSent = 0;
163
+ #keepAlive: ReturnType<typeof setTimeout> | undefined;
158
164
  #queue: Promise<unknown> = Promise.resolve();
165
+ /** Connected with no `server`, so the `events` are registered by the first `use()`. */
166
+ #eventsPending = false;
159
167
  #closed = true;
160
168
  /**
161
169
  * Bumped by `disconnect()`. A connection attempt that was in flight compares
@@ -217,6 +225,8 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
217
225
  this.#options = normalizeQueryOptions(options);
218
226
  this.#logger = this.#options.logger ?? noopLogger;
219
227
  this.#notifications = this.#options.notificationsSupported;
228
+ // Refused here, not when the loop first runs: a bad policy would flood.
229
+ if (this.#options.reconnect !== undefined) resolveBackoffPolicy(this.#options.reconnect);
220
230
  this.#reconnectOptions = this.#options.reconnect;
221
231
  const middlewares: readonly CommandMiddleware[] = this.#options.commandMiddleware ?? [];
222
232
  // Built once: the chain is read on every command, and changing it while
@@ -417,26 +427,83 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
417
427
  transport.onLine = (line) => this.#handleLine(line);
418
428
  transport.onClose = (error) => this.#handleClose(error);
419
429
 
420
- const { transport: resolvedTransport, server, nickname, eventCategories } = this.#options;
430
+ const { transport: resolvedTransport, server, nickname } = this.#options;
421
431
  const login = resolvedTransport.kind === "tcp" ? resolvedTransport.login : undefined;
422
- if (login !== undefined) await this.login(login.name, login.password, { signal });
432
+ if (login !== undefined) {
433
+ await this.login(login.name, login.password, { signal }).catch((error: unknown) => {
434
+ noteConnectFailure("login", error);
435
+ throw error;
436
+ });
437
+ }
423
438
  // Not `use()`: this session learns who it is, and who is there, once, below.
424
- if (server !== undefined) await this.#select(server, { nickname, signal });
439
+ if (server !== undefined) {
440
+ await this.#select(server, { nickname, signal }).catch((error: unknown) => {
441
+ noteConnectFailure("use", error);
442
+ throw error;
443
+ });
444
+ }
425
445
  this.#version = await this.#getVersion(signal);
426
446
  this.#serverFamily = serverFamilyForVersion(this.#version);
427
447
  // Like the version, a failure here fails the connect: a client that cannot
428
448
  // tell which one it is would miss its own kick without saying so.
429
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;
430
457
  if (this.#notifications) {
431
- for (const category of eventCategories) {
432
- // The channel category takes the channel to watch, or 0 for all of them;
433
- // the others take none, and the server refuses one they do not know.
434
- await this.#register(category, category === "channel" ? 0 : undefined, { signal });
435
- }
436
- // Only worth listing when there is something to fill: the groups and the
437
- // invoker of a message this client would otherwise not know.
438
- if (eventCategories.length > 0) await this.#learnClients(signal);
458
+ if (server === undefined) this.#eventsPending = true;
459
+ else await this.#registerEvents(signal);
439
460
  }
461
+ this.#startKeepAlive(transport);
462
+ }
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
+
479
+ /**
480
+ * Keep a raw TCP or SSH session from idling out: the server closes one that
481
+ * has sent no command for 300 seconds (see `QueryClientOptions.keepAliveMs`),
482
+ * so `version` goes out once none has for that long. Only commands count, as
483
+ * the server counts them; what the server sends does not.
484
+ */
485
+ #startKeepAlive(transport: QueryTransport): void {
486
+ const every = this.#options.keepAliveMs;
487
+ const kind = this.#options.transport.kind;
488
+ if (every === 0 || (kind !== "tcp" && kind !== "ssh")) return;
489
+ const tick = (): void => {
490
+ if (this.#transport !== transport || this.#closed) return;
491
+ const idle = Date.now() - this.#lastSent;
492
+ if (idle >= every) {
493
+ this.#logger.debug("keeping the idle session open", { idleMs: idle });
494
+ // A failure is the connection dropping, which its own close reports.
495
+ this.exec("version").catch(() => {});
496
+ this.#keepAlive = setTimeout(tick, every);
497
+ } else {
498
+ this.#keepAlive = setTimeout(tick, every - idle);
499
+ }
500
+ // The socket keeps the process alive; this timer must not.
501
+ this.#keepAlive.unref();
502
+ };
503
+ clearTimeout(this.#keepAlive);
504
+ this.#lastSent = Date.now();
505
+ this.#keepAlive = setTimeout(tick, every);
506
+ this.#keepAlive.unref();
440
507
  }
441
508
 
442
509
  /**
@@ -473,6 +540,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
473
540
  this.#logger.info("disconnecting from query server");
474
541
  const transport = this.#transport;
475
542
  this.#transport = null;
543
+ clearTimeout(this.#keepAlive);
476
544
  // Set before the transport is closed, so the close it reports is not read
477
545
  // as the connection dropping.
478
546
  this.#closed = true;
@@ -566,6 +634,10 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
566
634
  * and the notifications registered are gone, so `use` again and
567
635
  * {@link QueryClient.registerNotifications} again.
568
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
+ *
569
641
  * A reconnect starts over from the options the client was made with: it logs
570
642
  * in, selects `server` and registers `events` again, and does not repeat a
571
643
  * `use` made by hand.
@@ -579,7 +651,10 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
579
651
  // none has no use for.
580
652
  if (!this.#notifications) return;
581
653
  await this.#learnSelf(options.signal);
582
- 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);
583
658
  }
584
659
 
585
660
  /** The `use` command, and what it does to what the client knows. */
@@ -870,8 +945,8 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
870
945
  }
871
946
 
872
947
  /**
873
- * List the clients once, so that `invokerGroups` and the invoker fields of a
874
- * self-move can be filled: an enter notification is only seen for clients
948
+ * List the clients once, so that `invokerGroups` and the `fromChannelID` of
949
+ * a move can be filled: an enter notification is only seen for clients
875
950
  * that arrive after registering, and the ones already there arrive as none.
876
951
  * `-away` and `-voice` make the records carry away and mute state as well,
877
952
  * in the same one command. The groups are as of this listing: a ServerQuery
@@ -892,7 +967,49 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
892
967
  }
893
968
  }
894
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
+ */
895
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>>> {
896
1013
  const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
897
1014
  signal?.throwIfAborted();
898
1015
  if (this.#closed || this.#transport === null) throw new NotConnectedError();
@@ -917,8 +1034,12 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
917
1034
  const sending = { failed: false };
918
1035
  let abandonedByUs = false;
919
1036
  const transport = this.#transport;
1037
+ this.#lastSent = Date.now();
920
1038
  const nudges = this.#options.transport.kind === "ssh";
921
- const send = transport.send(line).then(
1039
+ // Ended with the command, so that a request nobody waits for any more is
1040
+ // not left running to answer the next command; see `QueryTransport.send`.
1041
+ const sendAbort = new AbortController();
1042
+ const send = transport.send(line, { signal: sendAbort.signal }).then(
922
1043
  () => {
923
1044
  // On TeamSpeak 6 over SSH the server may hold back the last line of a
924
1045
  // reply, usually `error id=0 msg=ok`, until the client writes again;
@@ -950,6 +1071,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
950
1071
  return await answered;
951
1072
  } finally {
952
1073
  clearTimeout(timer);
1074
+ sendAbort.abort();
953
1075
  nudgerStopped = true;
954
1076
  clearTimeout(nudger);
955
1077
  if (onAbort) signal?.removeEventListener("abort", onAbort);
@@ -1166,6 +1288,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
1166
1288
  #handleClose(error: Error | undefined): void {
1167
1289
  if (this.#status === ClientStatus.Disconnected) return;
1168
1290
  this.#transport = null;
1291
+ clearTimeout(this.#keepAlive);
1169
1292
  this.#closed = true;
1170
1293
  this.#fail(new NotConnectedError(error ? { cause: error } : undefined));
1171
1294
  const wasConnected = this.#status === ClientStatus.Connected;
@@ -1208,6 +1331,7 @@ export class QueryClient implements CommandExecutor, AsyncDisposable {
1208
1331
  this.#fail(error);
1209
1332
  const transport = this.#transport;
1210
1333
  this.#transport = null;
1334
+ clearTimeout(this.#keepAlive);
1211
1335
  void transport?.close().catch(() => {});
1212
1336
  if (!this.#reconnecting) return;
1213
1337
  const cause =
@@ -1373,16 +1497,16 @@ function serverIDOf(server: ResolvedQueryOptions["server"]): ServerID | number |
1373
1497
  }
1374
1498
 
1375
1499
  /** Sleep, or wake early once `signal` aborts. Resolves either way, never rejects. */
1376
- function sleep(ms: number, signal: AbortSignal): Promise<void> {
1377
- 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();
1378
1502
  return new Promise((resolve) => {
1379
1503
  const done = (): void => {
1380
1504
  clearTimeout(timer);
1381
- signal.removeEventListener("abort", done);
1505
+ signal?.removeEventListener("abort", done);
1382
1506
  resolve();
1383
1507
  };
1384
1508
  const timer = setTimeout(done, ms);
1385
- signal.addEventListener("abort", done, { once: true });
1509
+ signal?.addEventListener("abort", done, { once: true });
1386
1510
  });
1387
1511
  }
1388
1512
 
@@ -143,3 +143,37 @@ export class GuestAccessError extends TeamspeakError {
143
143
  this.code = code;
144
144
  }
145
145
  }
146
+
147
+ /**
148
+ * A line longer than 256 MiB came from the server, which ends
149
+ * the connection.
150
+ *
151
+ * ServerQuery writes an answer's rows on one line, so the limit is the largest
152
+ * answer: a client database listing is about 240 bytes a client, so 256 MiB
153
+ * holds about a million of them, and the longest answer measured on TeamSpeak 3
154
+ * and 6, the permission list, is under 50 KiB. A server that sends more is not
155
+ * speaking ServerQuery, and reading on would only fill memory.
156
+ *
157
+ * @experimental Part of the `@honeybbq/teamspeak/query` entry, which may
158
+ * change in a minor release.
159
+ */
160
+ export class LineTooLongError extends TeamspeakError {
161
+ /** {@inheritDoc @honeybbq/teamspeak!TeamspeakError.kind} */
162
+ static override readonly kind: string = "LineTooLongError";
163
+ static {
164
+ Object.defineProperty(this.prototype, "name", {
165
+ value: "LineTooLongError",
166
+ writable: true,
167
+ configurable: true,
168
+ enumerable: false,
169
+ });
170
+ }
171
+
172
+ /** The longest line read, in bytes. */
173
+ readonly limit: number;
174
+
175
+ constructor(limit: number) {
176
+ super(`the server sent a line longer than the limit of ${limit} bytes`);
177
+ this.limit = limit;
178
+ }
179
+ }
@@ -39,6 +39,7 @@ export { defaultShouldRetry } from "./reconnect.js";
39
39
  // Errors unique to ServerQuery.
40
40
  export {
41
41
  GuestAccessError,
42
+ LineTooLongError,
42
43
  MissingDependencyError,
43
44
  QueryAuthError,
44
45
  SshHostKeyError,
@@ -1,5 +1,14 @@
1
+ import { LineTooLongError } from "./errors.js";
1
2
  import { trimCarriageReturns } from "./trim.js";
2
3
 
4
+ /**
5
+ * The longest line a connection reads, in bytes: 256 MiB. A line is a whole
6
+ * answer, and a client database listing is about 240 bytes a client, so this
7
+ * holds about a million of them; the permission list, the longest answer
8
+ * measured on TeamSpeak 3 and 6, is under 50 KiB. See {@link LineTooLongError}.
9
+ */
10
+ export const MAX_LINE_BYTES: number = 256 * 1024 * 1024;
11
+
3
12
  /**
4
13
  * Splits a byte stream into the lines ServerQuery speaks.
5
14
  *
@@ -7,21 +16,44 @@ import { trimCarriageReturns } from "./trim.js";
7
16
  * start of the next line rather than the end of this one; both ends are
8
17
  * trimmed. Bytes are decoded as UTF-8 only once a whole line is in, so a
9
18
  * character split across two chunks arrives whole.
19
+ *
20
+ * Each byte is looked at once, however many chunks a line arrives in. A line
21
+ * past the limit throws {@link LineTooLongError}.
10
22
  */
11
23
  export class LineReader {
12
- #pending = Buffer.alloc(0);
24
+ /** The chunks of the line still being read; none holds a newline. */
25
+ #parts: Uint8Array[] = [];
26
+ #length = 0;
27
+ readonly #limit: number;
28
+
29
+ constructor(limit: number = MAX_LINE_BYTES) {
30
+ this.#limit = limit;
31
+ }
13
32
 
14
33
  /** Add a chunk and return the lines it completed, without their terminators. */
15
34
  push(chunk: Uint8Array): string[] {
16
- this.#pending = Buffer.concat([this.#pending, chunk]);
17
35
  const lines: string[] = [];
36
+ let start = 0;
18
37
  for (;;) {
19
- const end = this.#pending.indexOf(0x0a);
38
+ const end = Buffer.prototype.indexOf.call(chunk, 0x0a, start) as number;
20
39
  if (end < 0) break;
21
- const line = trimCarriageReturns(this.#pending.subarray(0, end).toString("utf8"));
22
- this.#pending = this.#pending.subarray(end + 1);
23
- lines.push(line);
40
+ this.#add(chunk.subarray(start, end));
41
+ lines.push(trimCarriageReturns(Buffer.concat(this.#parts, this.#length).toString("utf8")));
42
+ this.#parts = [];
43
+ this.#length = 0;
44
+ start = end + 1;
24
45
  }
46
+ this.#add(chunk.subarray(start));
25
47
  return lines;
26
48
  }
49
+
50
+ #add(part: Uint8Array): void {
51
+ this.#length += part.byteLength;
52
+ if (this.#length > this.#limit) {
53
+ this.#parts = [];
54
+ this.#length = 0;
55
+ throw new LineTooLongError(this.#limit);
56
+ }
57
+ if (part.byteLength > 0) this.#parts.push(part);
58
+ }
27
59
  }
@@ -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`). */
@@ -138,6 +142,14 @@ export interface QueryClientOptions {
138
142
  * Registering for anything on a transport that cannot deliver notifications
139
143
  * throws a `TypeError`, at construction when the `transport` kind says so
140
144
  * and when connecting for a `custom` transport.
145
+ *
146
+ * The client keeps a roster of who is on the server, to fill the fields a
147
+ * notification leaves out, such as a sender's groups: a snapshot taken on
148
+ * connecting (`clientlist -uid -groups -away -voice`), kept up to date by the
149
+ * enter, leave and move notifications. A ServerQuery connection is not sent
150
+ * `clientupdated`, so a client's groups, away flag and mute state are as of
151
+ * the listing, or of the moment it entered view, and do not follow later
152
+ * changes.
141
153
  */
142
154
  readonly events?: QueryEventsOption | undefined;
143
155
  /**
@@ -146,6 +158,21 @@ export interface QueryClientOptions {
146
158
  * decision is {@link defaultShouldRetry}.
147
159
  */
148
160
  readonly reconnect?: ReconnectOptions | undefined;
161
+ /**
162
+ * How long a raw TCP or SSH session may sit idle before the client sends
163
+ * `version` to keep it open, in milliseconds. 120000 (two minutes) by default;
164
+ * `0` turns it off.
165
+ *
166
+ * Measured on TeamSpeak 3 3.13.8 (raw TCP and SSH) and TeamSpeak 6
167
+ * 6.0.0-beta13.1 (SSH): the server closes a query session that has sent no
168
+ * command for 300 seconds, and a session that sends one every 200 seconds
169
+ * stays open. Notifications arriving do not count: a raw TCP session on
170
+ * TeamSpeak 3 that received nine and sent nothing was closed at 300 seconds
171
+ * all the same. The command is sent only when none has been
172
+ * for this long, so a busy client sends nothing extra. WebQuery has no session
173
+ * to keep, and a `custom` transport is left to its own owner.
174
+ */
175
+ readonly keepAliveMs?: number | undefined;
149
176
  /**
150
177
  * Wraps every typed command, outermost first; see `CommandMiddleware`. Raw
151
178
  * lines sent with `exec` do not pass through it. Fixed for the client's life.
@@ -218,9 +245,13 @@ export interface ResolvedQueryOptions {
218
245
  readonly eventsRequested: boolean;
219
246
  readonly reconnect: ReconnectOptions | undefined;
220
247
  readonly commandMiddleware: readonly CommandMiddleware[] | undefined;
248
+ readonly keepAliveMs: number;
221
249
  readonly logger: Logger | undefined;
222
250
  }
223
251
 
252
+ /** How long a session may be idle before the client keeps it open; the server drops one at 300 s. */
253
+ export const DEFAULT_KEEP_ALIVE_MS = 120_000;
254
+
224
255
  function nonEmpty(value: unknown, name: string): string {
225
256
  if (typeof value !== "string" || value === "") {
226
257
  throw new TypeError(`${name} is a non-empty string`);
@@ -449,6 +480,10 @@ export function normalizeQueryOptions(options: QueryClientOptions): ResolvedQuer
449
480
  throw new RangeError("server.port is an integer from 1 to 65535");
450
481
  }
451
482
  }
483
+ const keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
484
+ if (!Number.isInteger(keepAliveMs) || keepAliveMs < 0) {
485
+ throw new RangeError("keepAliveMs is a non-negative integer, 0 to turn it off");
486
+ }
452
487
  const nickname = options.nickname;
453
488
  if (nickname !== undefined) nonEmpty(nickname, "nickname");
454
489
  const commandMiddleware = options.commandMiddleware;
@@ -465,6 +500,7 @@ export function normalizeQueryOptions(options: QueryClientOptions): ResolvedQuer
465
500
  eventsRequested: options.events !== undefined,
466
501
  reconnect: options.reconnect,
467
502
  commandMiddleware,
503
+ keepAliveMs,
468
504
  logger: options.logger,
469
505
  };
470
506
  assertEventsSupported(resolved);
@@ -12,15 +12,33 @@ import { MissingDependencyError, QueryAuthError, SshHostKeyError } from "./error
12
12
  * reconnect loop is most likely to meet right after a drop.
13
13
  */
14
14
  const PERMANENT_SERVER_ERRORS: ReadonlySet<number> = new Set([
15
- ErrorCode.ClientInvalidPassword,
16
- ErrorCode.ClientNotLoggedIn,
17
- ErrorCode.ClientLoginNotPermitted,
18
- ErrorCode.ServerInvalidID,
19
- ErrorCode.ConnectFailedBanned,
20
- ErrorCode.BanFlooding,
21
- ErrorCode.PermissionsClientInsufficient,
15
+ ErrorCode.ClientInvalidPassword, // login: wrong name or password
16
+ ErrorCode.ClientNotLoggedIn, // use: this session has no login, and none is configured
17
+ ErrorCode.ClientLoginNotPermitted, // login: this account may not log in
18
+ ErrorCode.ServerInvalidID, // use: no such server
19
+ ErrorCode.ServerIsNotRunning, // use: that server is down
20
+ ErrorCode.ConnectFailedBanned, // retrying while banned extends the ban
21
+ ErrorCode.BanFlooding, // retrying while flood-banned extends the ban
22
+ ErrorCode.PermissionsClientInsufficient, // the account lacks the permission it needs
22
23
  ]);
23
24
 
25
+ /**
26
+ * Errors of a `login` or `use` that {@link isPermanentCommandError} found
27
+ * permanent. The error is the same object `shouldRetry` is later given, so
28
+ * the mark travels with it without changing its type.
29
+ */
30
+ const connectRefusals = new WeakSet();
31
+
32
+ /**
33
+ * Record why a command of `connect()` failed, so that the default
34
+ * `shouldRetry` can tell a refused `login` or `use` from any other failure.
35
+ */
36
+ export function noteConnectFailure(command: string, error: unknown): void {
37
+ if (typeof error === "object" && error !== null && isPermanentCommandError(command, error)) {
38
+ connectRefusals.add(error);
39
+ }
40
+ }
41
+
24
42
  /** Commands whose refusal is the credentials or the target, not the network. */
25
43
  const CONNECT_COMMANDS: ReadonlySet<string> = new Set(["login", "use"]);
26
44
 
@@ -47,6 +65,8 @@ const TRANSIENT_SERVER_ERRORS: ReadonlySet<number> = new Set([ErrorCode.ClientIs
47
65
  * A custom `shouldRetry` replaces this; see `ReconnectOptions`.
48
66
  */
49
67
  export function defaultShouldRetry(error: Error | undefined): boolean {
68
+ // A refused `login` or `use`, whatever its code; see `noteConnectFailure`.
69
+ if (error !== undefined && connectRefusals.has(error)) return false;
50
70
  // The client is connected, or connecting, already: nothing is left to retry.
51
71
  if (error instanceof AlreadyConnectedError) return false;
52
72
  // An account the server refused, or a transport that is on the wrong side