@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.
- 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 +36 -14
- package/dist/client/api/files.d.ts.map +1 -1
- package/dist/client/api/files.js +58 -9
- 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/api/voice.d.ts +2 -1
- package/dist/client/api/voice.d.ts.map +1 -1
- package/dist/client/api/voice.js +6 -2
- package/dist/client/api/voice.js.map +1 -1
- package/dist/client/client.d.ts +13 -3
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +151 -49
- package/dist/client/client.js.map +1 -1
- package/dist/client/crypto/crypt.d.ts +6 -0
- package/dist/client/crypto/crypt.d.ts.map +1 -1
- package/dist/client/crypto/crypt.js +13 -7
- package/dist/client/crypto/crypt.js.map +1 -1
- package/dist/client/crypto/eax.d.ts +4 -0
- package/dist/client/crypto/eax.d.ts.map +1 -1
- package/dist/client/crypto/eax.js +27 -21
- package/dist/client/crypto/eax.js.map +1 -1
- package/dist/client/errors.d.ts +13 -0
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +23 -0
- package/dist/client/errors.js.map +1 -1
- package/dist/client/handshake/crypt-handshake.d.ts +2 -1
- package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/client/handshake/crypt-handshake.js +4 -2
- package/dist/client/handshake/crypt-handshake.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/throttle.d.ts +11 -0
- package/dist/client/throttle.d.ts.map +1 -1
- package/dist/client/throttle.js +38 -3
- package/dist/client/throttle.js.map +1 -1
- package/dist/client/transfer.d.ts +37 -4
- package/dist/client/transfer.d.ts.map +1 -1
- package/dist/client/transfer.js +155 -15
- package/dist/client/transfer.js.map +1 -1
- package/dist/client/transport/handler.d.ts.map +1 -1
- package/dist/client/transport/handler.js +47 -43
- package/dist/client/transport/handler.js.map +1 -1
- package/dist/core/api/clients.d.ts +15 -15
- package/dist/core/api/clients.d.ts.map +1 -1
- package/dist/core/api/clients.js.map +1 -1
- package/dist/core/api/command.d.ts +8 -6
- package/dist/core/api/command.d.ts.map +1 -1
- package/dist/core/api/command.js +1 -1
- package/dist/core/api/command.js.map +1 -1
- package/dist/core/api/files.d.ts +23 -6
- package/dist/core/api/files.d.ts.map +1 -1
- package/dist/core/api/files.js +12 -9
- package/dist/core/api/files.js.map +1 -1
- package/dist/core/api/groups.d.ts +17 -2
- package/dist/core/api/groups.d.ts.map +1 -1
- package/dist/core/api/groups.js +13 -11
- package/dist/core/api/groups.js.map +1 -1
- package/dist/core/api/parse.d.ts +5 -5
- package/dist/core/api/parse.d.ts.map +1 -1
- package/dist/core/api/parse.js +16 -15
- package/dist/core/api/parse.js.map +1 -1
- package/dist/core/command/request.d.ts +6 -0
- package/dist/core/command/request.d.ts.map +1 -1
- package/dist/core/command/request.js +12 -0
- package/dist/core/command/request.js.map +1 -1
- package/dist/core/errors.d.ts +46 -3
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +73 -20
- package/dist/core/errors.js.map +1 -1
- package/dist/core/events.d.ts +24 -8
- package/dist/core/events.d.ts.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/core/notifications.d.ts +1 -1
- package/dist/core/notifications.js +9 -8
- package/dist/core/notifications.js.map +1 -1
- package/dist/core/reconnect.d.ts +16 -5
- package/dist/core/reconnect.d.ts.map +1 -1
- package/dist/core/reconnect.js +32 -4
- package/dist/core/reconnect.js.map +1 -1
- package/dist/core/types.d.ts +15 -16
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.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 +145 -23
- package/dist/query/client.js.map +1 -1
- package/dist/query/errors.d.ts +20 -0
- package/dist/query/errors.d.ts.map +1 -1
- package/dist/query/errors.js +31 -0
- package/dist/query/errors.js.map +1 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/index.js.map +1 -1
- package/dist/query/lines.d.ts +11 -0
- package/dist/query/lines.d.ts.map +1 -1
- package/dist/query/lines.js +36 -6
- package/dist/query/lines.js.map +1 -1
- package/dist/query/options.d.ts +30 -0
- package/dist/query/options.d.ts.map +1 -1
- package/dist/query/options.js +7 -0
- package/dist/query/options.js.map +1 -1
- package/dist/query/reconnect.d.ts +5 -0
- package/dist/query/reconnect.d.ts.map +1 -1
- package/dist/query/reconnect.js +26 -7
- package/dist/query/reconnect.js.map +1 -1
- package/dist/query/ssh.js +9 -4
- package/dist/query/ssh.js.map +1 -1
- package/dist/query/stream.d.ts +2 -0
- package/dist/query/stream.d.ts.map +1 -1
- package/dist/query/stream.js +19 -1
- package/dist/query/stream.js.map +1 -1
- package/dist/query/transport.d.ts +12 -2
- package/dist/query/transport.d.ts.map +1 -1
- package/dist/query/web.d.ts +3 -1
- package/dist/query/web.d.ts.map +1 -1
- package/dist/query/web.js +82 -18
- package/dist/query/web.js.map +1 -1
- package/dist/stream/published.d.ts.map +1 -1
- package/dist/stream/published.js +61 -25
- package/dist/stream/published.js.map +1 -1
- package/dist/stream/streams.d.ts.map +1 -1
- package/dist/stream/streams.js +51 -10
- package/dist/stream/streams.js.map +1 -1
- package/dist/stream/wire.d.ts +2 -2
- package/dist/stream/wire.d.ts.map +1 -1
- package/dist/stream/wire.js +27 -7
- package/dist/stream/wire.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api/avatars.ts +15 -2
- package/src/client/api/files.ts +83 -23
- package/src/client/api/upload-checks.ts +96 -0
- package/src/client/api/voice.ts +5 -2
- package/src/client/client.ts +154 -42
- package/src/client/crypto/crypt.ts +15 -7
- package/src/client/crypto/eax.ts +27 -22
- package/src/client/errors.ts +25 -0
- package/src/client/handshake/crypt-handshake.ts +6 -3
- package/src/client/index.ts +1 -0
- package/src/client/throttle.ts +41 -3
- package/src/client/transfer.ts +172 -28
- package/src/client/transport/handler.ts +54 -46
- package/src/core/api/clients.ts +24 -21
- package/src/core/api/command.ts +9 -7
- package/src/core/api/files.ts +30 -16
- package/src/core/api/groups.ts +32 -13
- package/src/core/api/parse.ts +16 -15
- package/src/core/command/request.ts +18 -0
- package/src/core/errors.ts +74 -20
- package/src/core/events.ts +24 -8
- package/src/core/internal.ts +7 -1
- package/src/core/notifications.ts +9 -8
- package/src/core/reconnect.ts +60 -8
- package/src/core/types.ts +15 -16
- package/src/query/client.ts +144 -20
- package/src/query/errors.ts +34 -0
- package/src/query/index.ts +1 -0
- package/src/query/lines.ts +38 -6
- package/src/query/options.ts +36 -0
- package/src/query/reconnect.ts +27 -7
- package/src/query/ssh.ts +11 -4
- package/src/query/stream.ts +17 -1
- package/src/query/testing/live.ts +4 -4
- package/src/query/transport.ts +10 -2
- package/src/query/web.ts +76 -21
- package/src/stream/published.ts +70 -23
- package/src/stream/streams.ts +47 -13
- package/src/stream/wire.ts +30 -11
package/src/core/reconnect.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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`. */
|
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,
|
|
@@ -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
|
|
430
|
+
const { transport: resolvedTransport, server, nickname } = this.#options;
|
|
421
431
|
const login = resolvedTransport.kind === "tcp" ? resolvedTransport.login : undefined;
|
|
422
|
-
if (login !== undefined)
|
|
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)
|
|
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
|
-
|
|
432
|
-
|
|
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.#
|
|
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
|
|
874
|
-
*
|
|
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
|
-
|
|
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
|
|
1377
|
-
if (ms <= 0 || signal
|
|
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
|
|
1505
|
+
signal?.removeEventListener("abort", done);
|
|
1382
1506
|
resolve();
|
|
1383
1507
|
};
|
|
1384
1508
|
const timer = setTimeout(done, ms);
|
|
1385
|
-
signal
|
|
1509
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
1386
1510
|
});
|
|
1387
1511
|
}
|
|
1388
1512
|
|
package/src/query/errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/query/index.ts
CHANGED
package/src/query/lines.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
38
|
+
const end = Buffer.prototype.indexOf.call(chunk, 0x0a, start) as number;
|
|
20
39
|
if (end < 0) break;
|
|
21
|
-
|
|
22
|
-
this.#
|
|
23
|
-
|
|
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
|
}
|
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`). */
|
|
@@ -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);
|
package/src/query/reconnect.ts
CHANGED
|
@@ -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.
|
|
20
|
-
ErrorCode.
|
|
21
|
-
ErrorCode.
|
|
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
|