@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.
- package/dist/client/api/avatars.d.ts.map +1 -1
- package/dist/client/api/avatars.js +11 -2
- package/dist/client/api/avatars.js.map +1 -1
- package/dist/client/api/files.d.ts +9 -2
- package/dist/client/api/files.d.ts.map +1 -1
- package/dist/client/api/files.js +15 -5
- package/dist/client/api/files.js.map +1 -1
- package/dist/client/api/upload-checks.d.ts +36 -0
- package/dist/client/api/upload-checks.d.ts.map +1 -0
- package/dist/client/api/upload-checks.js +77 -0
- package/dist/client/api/upload-checks.js.map +1 -0
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +5 -12
- package/dist/client/client.js.map +1 -1
- package/dist/client/transfer.d.ts +14 -0
- package/dist/client/transfer.d.ts.map +1 -1
- package/dist/client/transfer.js +43 -6
- package/dist/client/transfer.js.map +1 -1
- package/dist/core/api/files.d.ts +5 -1
- package/dist/core/api/files.d.ts.map +1 -1
- package/dist/core/api/files.js +5 -1
- package/dist/core/api/files.js.map +1 -1
- package/dist/core/errors.d.ts +33 -3
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +41 -5
- package/dist/core/errors.js.map +1 -1
- package/dist/core/internal.d.ts +1 -1
- package/dist/core/internal.d.ts.map +1 -1
- package/dist/core/internal.js +1 -1
- package/dist/core/internal.js.map +1 -1
- package/dist/query/client.d.ts +4 -0
- package/dist/query/client.d.ts.map +1 -1
- package/dist/query/client.js +82 -15
- package/dist/query/client.js.map +1 -1
- package/dist/query/options.d.ts +4 -0
- package/dist/query/options.d.ts.map +1 -1
- package/dist/query/options.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api/avatars.ts +15 -2
- package/src/client/api/files.ts +21 -5
- package/src/client/api/upload-checks.ts +96 -0
- package/src/client/client.ts +6 -12
- package/src/client/transfer.ts +45 -9
- package/src/core/api/files.ts +5 -1
- package/src/core/errors.ts +41 -5
- package/src/core/internal.ts +7 -1
- package/src/query/client.ts +84 -14
- package/src/query/options.ts +4 -0
package/src/client/api/files.ts
CHANGED
|
@@ -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,
|
|
177
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
|
|
963
|
+
hold !== undefined &&
|
|
970
964
|
!goodbye &&
|
|
971
965
|
refused < FLOOD_RETRIES &&
|
|
972
|
-
|
|
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(
|
|
972
|
+
this.#throttle.hold(hold);
|
|
979
973
|
this.#cmdTrack.unregister(rc);
|
|
980
974
|
[rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
|
|
981
975
|
continue;
|
package/src/client/transfer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
package/src/core/api/files.ts
CHANGED
|
@@ -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.
|
|
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,
|
package/src/core/errors.ts
CHANGED
|
@@ -251,9 +251,29 @@ export function parseErrorCode(raw: string | undefined): number {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/**
|
|
254
|
-
* How
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
|
269
|
-
|
|
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
|
/**
|
package/src/core/internal.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/query/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
447
|
-
|
|
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.#
|
|
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
|
|
1431
|
-
if (ms <= 0 || signal
|
|
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
|
|
1505
|
+
signal?.removeEventListener("abort", done);
|
|
1436
1506
|
resolve();
|
|
1437
1507
|
};
|
|
1438
1508
|
const timer = setTimeout(done, ms);
|
|
1439
|
-
signal
|
|
1509
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
1440
1510
|
});
|
|
1441
1511
|
}
|
|
1442
1512
|
|
package/src/query/options.ts
CHANGED
|
@@ -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`). */
|