@honeybbq/teamspeak-client 0.3.0 → 1.0.0-beta.1
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/README.md +322 -60
- package/dist/abort.d.ts +5 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/address.d.ts +8 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/api/voice.d.ts +24 -0
- package/dist/api/voice.d.ts.map +1 -0
- package/dist/capabilities.d.ts +62 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/client.d.ts +172 -39
- package/dist/client.d.ts.map +1 -1
- package/dist/commands.d.ts +45 -27
- package/dist/commands.d.ts.map +1 -1
- package/dist/crypto/crypt.d.ts +19 -4
- package/dist/crypto/crypt.d.ts.map +1 -1
- package/dist/crypto/eax.d.ts.map +1 -1
- package/dist/crypto/identity.d.ts +87 -8
- package/dist/crypto/identity.d.ts.map +1 -1
- package/dist/crypto/index.d.ts +2 -1
- package/dist/crypto/index.d.ts.map +1 -1
- package/dist/diagnostics.d.ts +45 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/discovery/resolver.d.ts +21 -3
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +70 -16
- package/dist/errors.d.ts.map +1 -1
- package/dist/events.d.ts +4 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +1 -0
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake/crypt-init2.d.ts.map +1 -1
- package/dist/handshake/license.d.ts +7 -0
- package/dist/handshake/license.d.ts.map +1 -1
- package/dist/handshake.d.ts +14 -5
- package/dist/handshake.d.ts.map +1 -1
- package/dist/helpers.d.ts +0 -8
- package/dist/helpers.d.ts.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3967 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +7 -15
- package/dist/notifications.d.ts.map +1 -1
- package/dist/reconnect.d.ts +69 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/throttle.d.ts +9 -0
- package/dist/throttle.d.ts.map +1 -1
- package/dist/transfer.d.ts +27 -25
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/generation-window.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +37 -5
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/transport/quicklz.d.ts +19 -0
- package/dist/transport/quicklz.d.ts.map +1 -1
- package/dist/types.d.ts +277 -97
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -57
- package/src/abort.ts +24 -0
- package/src/address.ts +74 -0
- package/src/api/voice.ts +61 -0
- package/src/capabilities.ts +92 -0
- package/src/client.ts +1392 -0
- package/src/commands.ts +193 -0
- package/src/crypto/crypt-ops.ts +6 -0
- package/src/crypto/crypt.ts +274 -0
- package/src/crypto/eax.ts +152 -0
- package/src/crypto/identity.ts +484 -0
- package/src/crypto/index.ts +21 -0
- package/src/crypto/primitives.ts +141 -0
- package/src/diagnostics.ts +58 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/resolver.ts +272 -0
- package/src/errors.ts +156 -0
- package/src/events.ts +28 -0
- package/src/handshake/crypt-handshake.ts +135 -0
- package/src/handshake/crypt-init2.ts +48 -0
- package/src/handshake/index.ts +3 -0
- package/src/handshake/license.test.ts +49 -0
- package/src/handshake/license.ts +208 -0
- package/src/handshake.ts +136 -0
- package/src/helpers.ts +10 -0
- package/src/index.ts +74 -0
- package/src/notifications.ts +136 -0
- package/src/reconnect.ts +141 -0
- package/src/throttle.ts +92 -0
- package/src/transfer.ts +127 -0
- package/src/transport/generation-window.ts +77 -0
- package/src/transport/handler.ts +914 -0
- package/src/transport/index.ts +14 -0
- package/src/transport/packet.ts +69 -0
- package/src/transport/quicklz.ts +209 -0
- package/src/types.ts +349 -0
- package/dist/address-BR-5unVo.cjs +0 -2
- package/dist/address-BR-5unVo.cjs.map +0 -1
- package/dist/address-BfmovVpp.js +0 -65
- package/dist/address-BfmovVpp.js.map +0 -1
- package/dist/address.test.d.ts +0 -2
- package/dist/address.test.d.ts.map +0 -1
- package/dist/api.d.ts +0 -17
- package/dist/api.d.ts.map +0 -1
- package/dist/api.test.d.ts +0 -2
- package/dist/api.test.d.ts.map +0 -1
- package/dist/command/command.d.ts +0 -12
- package/dist/command/command.d.ts.map +0 -1
- package/dist/command/command.test.d.ts +0 -2
- package/dist/command/command.test.d.ts.map +0 -1
- package/dist/command/index.cjs +0 -1
- package/dist/command/index.d.ts +0 -4
- package/dist/command/index.d.ts.map +0 -1
- package/dist/command/index.mjs +0 -3
- package/dist/command/parser.d.ts +0 -3
- package/dist/command/parser.d.ts.map +0 -1
- package/dist/command-BLe-C2L2.js +0 -74
- package/dist/command-BLe-C2L2.js.map +0 -1
- package/dist/command-DAK4pUBP.cjs +0 -4
- package/dist/command-DAK4pUBP.cjs.map +0 -1
- package/dist/crypt-handshake-CQkQreLx.cjs +0 -2
- package/dist/crypt-handshake-CQkQreLx.cjs.map +0 -1
- package/dist/crypt-handshake-DziOY4Lq.js +0 -55
- package/dist/crypt-handshake-DziOY4Lq.js.map +0 -1
- package/dist/crypt-init2-D70ha4LB.js +0 -158
- package/dist/crypt-init2-D70ha4LB.js.map +0 -1
- package/dist/crypt-init2-DparLekm.cjs +0 -2
- package/dist/crypt-init2-DparLekm.cjs.map +0 -1
- package/dist/crypto/eax.test.d.ts +0 -2
- package/dist/crypto/eax.test.d.ts.map +0 -1
- package/dist/crypto/identity.test.d.ts +0 -2
- package/dist/crypto/identity.test.d.ts.map +0 -1
- package/dist/crypto/index.cjs +0 -1
- package/dist/crypto/index.mjs +0 -3
- package/dist/crypto-Bg02m4ZE.js +0 -165
- package/dist/crypto-Bg02m4ZE.js.map +0 -1
- package/dist/crypto-CDlweUL3.cjs +0 -2
- package/dist/crypto-CDlweUL3.cjs.map +0 -1
- package/dist/discovery/index.cjs +0 -1
- package/dist/discovery/index.mjs +0 -2
- package/dist/handler-DI_abe9c.js +0 -483
- package/dist/handler-DI_abe9c.js.map +0 -1
- package/dist/handler-DuwoO5RC.cjs +0 -2
- package/dist/handler-DuwoO5RC.cjs.map +0 -1
- package/dist/handshake/index.cjs +0 -1
- package/dist/handshake/index.mjs +0 -3
- package/dist/handshake.test.d.ts +0 -2
- package/dist/handshake.test.d.ts.map +0 -1
- package/dist/helpers.test.d.ts +0 -2
- package/dist/helpers.test.d.ts.map +0 -1
- package/dist/index.cjs +0 -4
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs +0 -796
- package/dist/index.mjs.map +0 -1
- package/dist/integration.test.d.ts +0 -12
- package/dist/integration.test.d.ts.map +0 -1
- package/dist/notifications.test.d.ts +0 -2
- package/dist/notifications.test.d.ts.map +0 -1
- package/dist/parser-CL33R9tr.cjs +0 -2
- package/dist/parser-CL33R9tr.cjs.map +0 -1
- package/dist/parser-DZMv3Jm-.js +0 -33
- package/dist/parser-DZMv3Jm-.js.map +0 -1
- package/dist/primitives-cNNAeFCo.cjs +0 -2
- package/dist/primitives-cNNAeFCo.cjs.map +0 -1
- package/dist/primitives-xIr6Tjk4.js +0 -1457
- package/dist/primitives-xIr6Tjk4.js.map +0 -1
- package/dist/resolver-6B8dI9gr.js +0 -141
- package/dist/resolver-6B8dI9gr.js.map +0 -1
- package/dist/resolver-DCcmyWSK.cjs +0 -4
- package/dist/resolver-DCcmyWSK.cjs.map +0 -1
- package/dist/throttle.test.d.ts +0 -2
- package/dist/throttle.test.d.ts.map +0 -1
- package/dist/transport/generation-window.test.d.ts +0 -2
- package/dist/transport/generation-window.test.d.ts.map +0 -1
- package/dist/transport/index.cjs +0 -1
- package/dist/transport/index.mjs +0 -2
- package/dist/transport/packet.test.d.ts +0 -2
- package/dist/transport/packet.test.d.ts.map +0 -1
- package/dist/transport/quicklz.test.d.ts +0 -2
- package/dist/transport/quicklz.test.d.ts.map +0 -1
- package/dist/transport/whisper.test.d.ts +0 -2
- package/dist/transport/whisper.test.d.ts.map +0 -1
package/src/client.ts
ADDED
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EventMap,
|
|
3
|
+
type ClientOptions,
|
|
4
|
+
type EventMiddleware,
|
|
5
|
+
type AddrResolver,
|
|
6
|
+
type ClientEnterEvent,
|
|
7
|
+
type KickedEvent,
|
|
8
|
+
type Listener,
|
|
9
|
+
type ServerEvent,
|
|
10
|
+
type ServerEventName,
|
|
11
|
+
type Unsubscribe,
|
|
12
|
+
ClientStatus,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import {
|
|
15
|
+
type ChannelID,
|
|
16
|
+
type ClientID,
|
|
17
|
+
type Codec,
|
|
18
|
+
type CommandMiddleware,
|
|
19
|
+
type CommandOptions,
|
|
20
|
+
type UID,
|
|
21
|
+
asChannelID,
|
|
22
|
+
asClientID,
|
|
23
|
+
type Logger,
|
|
24
|
+
ErrorCode,
|
|
25
|
+
Reason,
|
|
26
|
+
TeamspeakError,
|
|
27
|
+
noopLogger,
|
|
28
|
+
} from "@honeybbq/teamspeak-core";
|
|
29
|
+
import { AlreadyConnectedError, CryptoInitError, RemovedError } from "./errors.js";
|
|
30
|
+
import { abortable, abortReason } from "./abort.js";
|
|
31
|
+
import { Identity, Crypt } from "./crypto/index.js";
|
|
32
|
+
import { RESOLVE_BUDGET_MS, Resolver } from "./discovery/resolver.js";
|
|
33
|
+
import { PacketHandler } from "./transport/handler.js";
|
|
34
|
+
import { PacketType } from "./transport/packet.js";
|
|
35
|
+
import type { Packet } from "./transport/packet.js";
|
|
36
|
+
import { CommandTracker, appendReturnCode } from "./commands.js";
|
|
37
|
+
import { encodeRequest, parseServerError } from "@honeybbq/teamspeak-core/internal";
|
|
38
|
+
import { uploadFileData, downloadFileData } from "./transfer.js";
|
|
39
|
+
import { buildCommandChain, buildEventChain } from "./events.js";
|
|
40
|
+
import { processInit1 } from "./handshake/crypt-handshake.js";
|
|
41
|
+
import {
|
|
42
|
+
handleHandshakeInitIV,
|
|
43
|
+
handleHandshakeExpand2,
|
|
44
|
+
handleInitServer,
|
|
45
|
+
prepareClientPassword,
|
|
46
|
+
} from "./handshake.js";
|
|
47
|
+
import type { CommandExecutor, CommandRequest } from "@honeybbq/teamspeak-core";
|
|
48
|
+
import { handleNotification } from "./notifications.js";
|
|
49
|
+
import { parseCommand } from "@honeybbq/teamspeak-core/internal";
|
|
50
|
+
import { CommandThrottle } from "./throttle.js";
|
|
51
|
+
import { splitCommandRows, sleep } from "@honeybbq/teamspeak-core/internal";
|
|
52
|
+
import { isAutoNicknameMatch } from "./helpers.js";
|
|
53
|
+
import type { FileUploadInfo, FileDownloadInfo } from "@honeybbq/teamspeak-core";
|
|
54
|
+
import { CommandTimeoutError, NotConnectedError } from "@honeybbq/teamspeak-core";
|
|
55
|
+
import { responseSpec } from "@honeybbq/teamspeak-core/internal";
|
|
56
|
+
import type { Readable, Writable } from "node:stream";
|
|
57
|
+
import type { SocketFactory, FileTransferSocketFactory } from "./types.js";
|
|
58
|
+
import { unknownServerCapabilities, type ServerCapabilities } from "./capabilities.js";
|
|
59
|
+
import { joinHostPort, parseConnectAddress, splitHostPort } from "./address.js";
|
|
60
|
+
import { commandChannel, packetChannel, type CommandTrace } from "./diagnostics.js";
|
|
61
|
+
import {
|
|
62
|
+
backoffDelay,
|
|
63
|
+
defaultShouldRetry,
|
|
64
|
+
resolveBackoffPolicy,
|
|
65
|
+
type ReconnectOptions,
|
|
66
|
+
} from "./reconnect.js";
|
|
67
|
+
|
|
68
|
+
export { ClientStatus };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What clientinit sends about the connection, with the passwords already in
|
|
72
|
+
* their wire form, `base64(sha1(password))`: hashed once, when the client is
|
|
73
|
+
* made, so the plain text is never held.
|
|
74
|
+
*/
|
|
75
|
+
export interface ClientInitOptions {
|
|
76
|
+
readonly serverPasswordHash: string;
|
|
77
|
+
readonly defaultChannel: string;
|
|
78
|
+
readonly defaultChannelPasswordHash: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeClientInitOptions(options: ClientOptions): ClientInitOptions {
|
|
82
|
+
return {
|
|
83
|
+
serverPasswordHash: prepareClientPassword(options.serverPassword ?? ""),
|
|
84
|
+
defaultChannel: options.defaultChannel ?? "",
|
|
85
|
+
defaultChannelPasswordHash: prepareClientPassword(options.defaultChannelPassword ?? ""),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What the handshake, and the package's own tests, reach inside a Client.
|
|
91
|
+
* Not exported from the package: a Client keeps all of it in private fields,
|
|
92
|
+
* so none of it shows in `util.inspect()`, `JSON.stringify()` or a debugger's
|
|
93
|
+
* property list.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export interface ClientInternals {
|
|
98
|
+
readonly crypt: Crypt;
|
|
99
|
+
readonly handler: PacketHandler;
|
|
100
|
+
readonly logger: Logger;
|
|
101
|
+
readonly initOptions: ClientInitOptions;
|
|
102
|
+
nickname: string;
|
|
103
|
+
clid: number;
|
|
104
|
+
capabilities: ServerCapabilities;
|
|
105
|
+
/** Mark the attempt in progress connected. */
|
|
106
|
+
markConnected(): void;
|
|
107
|
+
/**
|
|
108
|
+
* Send a command line without waiting for an answer, on the handshake's
|
|
109
|
+
* own path: it needs an open socket, not a connected session.
|
|
110
|
+
*/
|
|
111
|
+
execNoWait(command: string): Promise<void>;
|
|
112
|
+
/** Handle a packet as if the current handler had received it. */
|
|
113
|
+
handlePacket(packet: Packet): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let internalsOf: (client: Client) => ClientInternals;
|
|
117
|
+
|
|
118
|
+
let sentLineHook: ((client: Client, line: string) => void) | undefined;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Observe every command line the client sends, as encoded. A seam for the
|
|
122
|
+
* package's own tests; not exported from the package.
|
|
123
|
+
*
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
export function onSentLine(hook: ((client: Client, line: string) => void) | undefined): void {
|
|
127
|
+
sentLineHook = hook;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @internal The internals of `client`. */
|
|
131
|
+
export function clientInternals(client: Client): ClientInternals {
|
|
132
|
+
return internalsOf(client);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How long a command waits for its answer unless told otherwise. */
|
|
136
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How long `disconnect()` waits for the server to acknowledge its goodbye
|
|
140
|
+
* unless told otherwise: the acknowledgement is a courtesy, and a connection
|
|
141
|
+
* that has already died would otherwise hold it for a full command timeout.
|
|
142
|
+
*/
|
|
143
|
+
const DISCONNECT_TIMEOUT_MS = 1_000;
|
|
144
|
+
|
|
145
|
+
/** How long each reconnection attempt may take. */
|
|
146
|
+
const RECONNECT_ATTEMPT_TIMEOUT_MS = 30_000;
|
|
147
|
+
|
|
148
|
+
/** One connection attempt, settled once by the handshake or its failure. */
|
|
149
|
+
interface ConnectAttempt {
|
|
150
|
+
readonly promise: Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Aborted when the attempt fails, is abandoned, or is interrupted by
|
|
153
|
+
* disconnect(), so the address lookup and the socket being opened for it
|
|
154
|
+
* stop too instead of finishing a connection nobody wants.
|
|
155
|
+
*/
|
|
156
|
+
readonly signal: AbortSignal;
|
|
157
|
+
settled: boolean;
|
|
158
|
+
resolve(): void;
|
|
159
|
+
reject(error: unknown): void;
|
|
160
|
+
/**
|
|
161
|
+
* Settle the attempt as failed now, which stops its lookup and socket, but
|
|
162
|
+
* reject its promise only once `until` settles: disconnect() holds it until
|
|
163
|
+
* `disconnected` has been delivered, so a caller that awaits disconnect()
|
|
164
|
+
* before the connect() it interrupted still hears that reject in time.
|
|
165
|
+
*/
|
|
166
|
+
rejectAfter(error: unknown, until: Promise<unknown>): void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function newConnectAttempt(): ConnectAttempt {
|
|
170
|
+
let resolve!: () => void;
|
|
171
|
+
let reject!: (error: unknown) => void;
|
|
172
|
+
const promise = new Promise<void>((res, rej) => {
|
|
173
|
+
resolve = res;
|
|
174
|
+
reject = rej;
|
|
175
|
+
});
|
|
176
|
+
// The failure is reported to whoever awaits connect(); an attempt nobody
|
|
177
|
+
// awaits any more must not surface as an unhandled rejection.
|
|
178
|
+
promise.catch(() => {});
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const attempt: ConnectAttempt = {
|
|
181
|
+
promise,
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
settled: false,
|
|
184
|
+
resolve() {
|
|
185
|
+
if (attempt.settled) return;
|
|
186
|
+
attempt.settled = true;
|
|
187
|
+
resolve();
|
|
188
|
+
},
|
|
189
|
+
reject(error) {
|
|
190
|
+
if (attempt.settled) return;
|
|
191
|
+
attempt.settled = true;
|
|
192
|
+
controller.abort(error);
|
|
193
|
+
reject(error);
|
|
194
|
+
},
|
|
195
|
+
rejectAfter(error, until) {
|
|
196
|
+
if (attempt.settled) return;
|
|
197
|
+
attempt.settled = true;
|
|
198
|
+
controller.abort(error);
|
|
199
|
+
void until.finally(() => reject(error));
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return attempt;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Voice deliveries are dropped while more than this many deliveries of any
|
|
207
|
+
* kind are waiting: audio is fifty packets a second per speaker, and a
|
|
208
|
+
* listener that cannot keep up would otherwise queue it without bound.
|
|
209
|
+
*/
|
|
210
|
+
const MAX_PENDING_DELIVERIES = 256;
|
|
211
|
+
|
|
212
|
+
/** How often dropped voice is reported, at most. */
|
|
213
|
+
const VOICE_DROP_WARN_INTERVAL_MS = 10_000;
|
|
214
|
+
|
|
215
|
+
/** The only commands handled before crypto init completes. */
|
|
216
|
+
const PRE_INIT_COMMANDS: ReadonlySet<string> = new Set(["clientinitiv", "initivexpand2", "error"]);
|
|
217
|
+
|
|
218
|
+
interface Subscription {
|
|
219
|
+
readonly listener: (payload: unknown) => void;
|
|
220
|
+
removed: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A TeamSpeak client over the voice protocol.
|
|
225
|
+
*
|
|
226
|
+
* Listeners run one at a time, in the order their events arrived, after the
|
|
227
|
+
* packet that carried them has been handled, so a listener may call the client
|
|
228
|
+
* and await an answer.
|
|
229
|
+
*/
|
|
230
|
+
export class Client implements CommandExecutor, AsyncDisposable {
|
|
231
|
+
static {
|
|
232
|
+
internalsOf = (client) => client.#internals;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#crypt: Crypt;
|
|
236
|
+
#handler: PacketHandler;
|
|
237
|
+
#logger: Logger;
|
|
238
|
+
#nickname: string;
|
|
239
|
+
#clid = 0;
|
|
240
|
+
/**
|
|
241
|
+
* What the server reported about itself in initserver, including the UID
|
|
242
|
+
* hash it uses: TS3 hashes the public key with SHA-1 and TS6 with SHA-256.
|
|
243
|
+
*/
|
|
244
|
+
#capabilities: ServerCapabilities = unknownServerCapabilities();
|
|
245
|
+
#internals: ClientInternals;
|
|
246
|
+
|
|
247
|
+
#identity: Identity;
|
|
248
|
+
#addr: string;
|
|
249
|
+
#resolver: AddrResolver;
|
|
250
|
+
#clientInitOptions: ClientInitOptions;
|
|
251
|
+
#socketFactory: SocketFactory | undefined;
|
|
252
|
+
#fileTransferSocketFactory: FileTransferSocketFactory | undefined;
|
|
253
|
+
/** Undefined when the client does not reconnect. */
|
|
254
|
+
#reconnectOptions: ReconnectOptions | undefined;
|
|
255
|
+
/**
|
|
256
|
+
* Set while a reconnect attempt is in flight, so overlapping failures do not
|
|
257
|
+
* start competing loops.
|
|
258
|
+
*/
|
|
259
|
+
#reconnecting = false;
|
|
260
|
+
/**
|
|
261
|
+
* Why the last established session dropped: a reconnect loop whose own
|
|
262
|
+
* attempt connected and then dropped before the loop resumed retries for it.
|
|
263
|
+
*/
|
|
264
|
+
#lastDrop: Error | undefined;
|
|
265
|
+
/** Set by an explicit disconnect(), which must never be undone by a retry. */
|
|
266
|
+
#stopped = false;
|
|
267
|
+
/**
|
|
268
|
+
* The goodbye of a disconnect() still in progress, which a connect() waits
|
|
269
|
+
* for: tearing the old session down after a new one has started would end
|
|
270
|
+
* the new one instead.
|
|
271
|
+
*/
|
|
272
|
+
#leaving: Promise<void> | undefined;
|
|
273
|
+
/** How many times disconnect() was called, so a waiting connect() sees one. */
|
|
274
|
+
#disconnects = 0;
|
|
275
|
+
/**
|
|
276
|
+
* Settles once the last queued `disconnected` has been delivered. A
|
|
277
|
+
* connect() waits for it, so the end of one session is never heard during
|
|
278
|
+
* the next: not by a listener added for the new session, and not by an
|
|
279
|
+
* events() iterator started for it.
|
|
280
|
+
*/
|
|
281
|
+
#disconnectedDelivered: Promise<void> | undefined;
|
|
282
|
+
/** Cuts short the reconnect loop's wait, on disconnect(). */
|
|
283
|
+
#stopReconnecting: AbortController | undefined;
|
|
284
|
+
#status: ClientStatus = ClientStatus.Disconnected;
|
|
285
|
+
#throttle = new CommandThrottle();
|
|
286
|
+
#cmdTrack = new CommandTracker(() => this.#clid);
|
|
287
|
+
#clients = new Map<number, ClientEnterEvent>();
|
|
288
|
+
#attempt: ConnectAttempt = newConnectAttempt();
|
|
289
|
+
|
|
290
|
+
#listeners = new Map<keyof EventMap, Subscription[]>();
|
|
291
|
+
/** Deliveries waiting for their turn; see #emit. */
|
|
292
|
+
#queue: Array<() => void> = [];
|
|
293
|
+
#draining = false;
|
|
294
|
+
#voiceDropped = 0;
|
|
295
|
+
#voiceDropWarnedAt = -Infinity;
|
|
296
|
+
|
|
297
|
+
#eventMiddlewares: readonly EventMiddleware[];
|
|
298
|
+
#send: CommandExecutor["send"];
|
|
299
|
+
#eventChain: (event: ServerEvent) => void;
|
|
300
|
+
|
|
301
|
+
constructor(identity: Identity, addr: string, nickname: string, options: ClientOptions = {}) {
|
|
302
|
+
this.#identity = identity;
|
|
303
|
+
this.#addr = addr;
|
|
304
|
+
this.#nickname = nickname;
|
|
305
|
+
this.#logger = options.logger ?? noopLogger;
|
|
306
|
+
this.#resolver =
|
|
307
|
+
options.resolver ?? new Resolver({ resolveNicknames: options.resolveNicknames });
|
|
308
|
+
this.#clientInitOptions = normalizeClientInitOptions(options);
|
|
309
|
+
this.#socketFactory = options.socketFactory;
|
|
310
|
+
this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
|
|
311
|
+
this.#reconnectOptions = options.reconnect;
|
|
312
|
+
|
|
313
|
+
this.#crypt = new Crypt(identity);
|
|
314
|
+
this.#handler = this.#newHandler();
|
|
315
|
+
|
|
316
|
+
// oxlint-disable-next-line no-this-alias -- accessors cannot be arrow functions
|
|
317
|
+
const self = this;
|
|
318
|
+
this.#internals = {
|
|
319
|
+
get crypt() {
|
|
320
|
+
return self.#crypt;
|
|
321
|
+
},
|
|
322
|
+
get handler() {
|
|
323
|
+
return self.#handler;
|
|
324
|
+
},
|
|
325
|
+
get logger() {
|
|
326
|
+
return self.#logger;
|
|
327
|
+
},
|
|
328
|
+
get initOptions() {
|
|
329
|
+
return self.#clientInitOptions;
|
|
330
|
+
},
|
|
331
|
+
get nickname() {
|
|
332
|
+
return self.#nickname;
|
|
333
|
+
},
|
|
334
|
+
set nickname(value) {
|
|
335
|
+
self.#nickname = value;
|
|
336
|
+
},
|
|
337
|
+
get clid() {
|
|
338
|
+
return self.#clid;
|
|
339
|
+
},
|
|
340
|
+
set clid(value) {
|
|
341
|
+
self.#clid = value;
|
|
342
|
+
},
|
|
343
|
+
get capabilities() {
|
|
344
|
+
return self.#capabilities;
|
|
345
|
+
},
|
|
346
|
+
set capabilities(value) {
|
|
347
|
+
self.#capabilities = value;
|
|
348
|
+
},
|
|
349
|
+
markConnected: () => this.#markConnected(),
|
|
350
|
+
execNoWait: (command) => this.#execNoWait(command, undefined, true),
|
|
351
|
+
handlePacket: (packet) => this.#handlePacket(packet),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Built once: the chains are read on every command and event, and
|
|
355
|
+
// changing them while those run would make the behaviour depend on timing.
|
|
356
|
+
const cmdMiddlewares: readonly CommandMiddleware[] = options.commandMiddleware ?? [];
|
|
357
|
+
this.#eventMiddlewares = options.eventMiddleware ?? [];
|
|
358
|
+
this.#send = buildCommandChain(cmdMiddlewares, (request, commandOptions = {}) =>
|
|
359
|
+
this.#exec(encodeRequest(request, prepareClientPassword), commandOptions, false),
|
|
360
|
+
);
|
|
361
|
+
this.#eventChain = buildEventChain([...this.#eventMiddlewares], (event) =>
|
|
362
|
+
this.#deliver(event.name, event.payload),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Where the client is in its connection's life. */
|
|
367
|
+
get status(): ClientStatus {
|
|
368
|
+
return this.#status;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- Connection -----------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Connect to the server. Resolves once the handshake completes.
|
|
375
|
+
*
|
|
376
|
+
* An address that cannot be one — no host, a port outside 1–65535 — rejects
|
|
377
|
+
* with an InvalidAddressError at once, before anything starts. A host name
|
|
378
|
+
* that does not resolve fails the attempt with a TeamspeakError whose
|
|
379
|
+
* `cause` is the lookup's error.
|
|
380
|
+
*
|
|
381
|
+
* A failed attempt rejects with its cause, such as the server's error for a
|
|
382
|
+
* rejected password. Aborting `signal` abandons the attempt and rejects with
|
|
383
|
+
* the signal's reason. The signal bounds only the connecting: once this
|
|
384
|
+
* resolves, the connection lasts until `disconnect()` or a drop.
|
|
385
|
+
* `connect()` does not retry; the `reconnect` option only retries a
|
|
386
|
+
* connection that drops after it was established.
|
|
387
|
+
*
|
|
388
|
+
* Called while a `disconnect()` is still saying goodbye, or while an earlier
|
|
389
|
+
* session's `disconnected` is still waiting to be delivered, this waits for
|
|
390
|
+
* that first, so the old session is closed, and its `disconnected`
|
|
391
|
+
* delivered, before the new one starts: no listener or `events()` iterator
|
|
392
|
+
* of the new session hears the old one end. A `disconnect()` called during
|
|
393
|
+
* that wait wins: this then rejects with a NotConnectedError.
|
|
394
|
+
*
|
|
395
|
+
* `connected` is delivered after this resolves, so a listener added right
|
|
396
|
+
* after `await client.connect()` still hears it.
|
|
397
|
+
*/
|
|
398
|
+
async connect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
|
|
399
|
+
const { signal } = options;
|
|
400
|
+
signal?.throwIfAborted();
|
|
401
|
+
const disconnects = this.#disconnects;
|
|
402
|
+
if (this.#leaving !== undefined) await abortable(this.#leaving, signal);
|
|
403
|
+
while (this.#disconnectedDelivered !== undefined) {
|
|
404
|
+
await abortable(this.#disconnectedDelivered, signal);
|
|
405
|
+
}
|
|
406
|
+
if (this.#disconnects !== disconnects) throw new NotConnectedError();
|
|
407
|
+
if (this.#status !== ClientStatus.Disconnected) throw new AlreadyConnectedError();
|
|
408
|
+
// Checked before anything changes: an address that can never work leaves
|
|
409
|
+
// the client as it was, with no attempt to announce the end of.
|
|
410
|
+
parseConnectAddress(this.#addr);
|
|
411
|
+
this.#stopped = false;
|
|
412
|
+
|
|
413
|
+
this.#resetForConnect();
|
|
414
|
+
this.#status = ClientStatus.Connecting;
|
|
415
|
+
const attempt = this.#attempt;
|
|
416
|
+
|
|
417
|
+
// The handshake may complete just as the signal fires, in which case
|
|
418
|
+
// #failConnection leaves the session alone and the caller gets the
|
|
419
|
+
// connection it made rather than one it would believe had failed.
|
|
420
|
+
const onAbort = (): void => this.#failConnection(abortReason(signal!));
|
|
421
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
422
|
+
try {
|
|
423
|
+
try {
|
|
424
|
+
const targetAddr = await this.#resolveAddr(attempt.signal);
|
|
425
|
+
if (!attempt.settled) {
|
|
426
|
+
this.#logger.info("connecting to server", { address: targetAddr });
|
|
427
|
+
await this.#handler.connect(targetAddr, attempt.signal);
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// Without this the client stays Connecting and every later connect()
|
|
431
|
+
// fails with AlreadyConnectedError.
|
|
432
|
+
this.#failConnection(error);
|
|
433
|
+
}
|
|
434
|
+
await attempt.promise;
|
|
435
|
+
} finally {
|
|
436
|
+
signal?.removeEventListener("abort", onAbort);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Leave the server and close the connection.
|
|
442
|
+
*
|
|
443
|
+
* Says goodbye first, waiting for the server's acknowledgement for a second
|
|
444
|
+
* at most, or less if `signal` aborts sooner. The connection is closed
|
|
445
|
+
* either way. A `connect()` still waiting for its handshake rejects with a
|
|
446
|
+
* NotConnectedError, and so do the commands still waiting for an answer.
|
|
447
|
+
* Resolves once `disconnected` has been delivered to the listeners, so one
|
|
448
|
+
* added after `await client.disconnect()` does not hear it. Does nothing on
|
|
449
|
+
* a client that is not connected; called again while an earlier call is
|
|
450
|
+
* still saying goodbye, it waits for that one.
|
|
451
|
+
*/
|
|
452
|
+
async disconnect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
|
|
453
|
+
// Recorded before anything else: a dropped socket during the goodbye below
|
|
454
|
+
// must not be mistaken for an outage worth retrying.
|
|
455
|
+
this.#stopped = true;
|
|
456
|
+
this.#disconnects++;
|
|
457
|
+
this.#stopReconnecting?.abort();
|
|
458
|
+
|
|
459
|
+
if (this.#leaving !== undefined) return this.#leaving;
|
|
460
|
+
// Already ended, by a drop or a failed attempt: its `disconnected` may
|
|
461
|
+
// still be on its way.
|
|
462
|
+
if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
|
|
463
|
+
|
|
464
|
+
const wasConnected = this.#status === ClientStatus.Connected;
|
|
465
|
+
this.#status = ClientStatus.Disconnected;
|
|
466
|
+
let left!: () => void;
|
|
467
|
+
this.#attempt.rejectAfter(new NotConnectedError(), new Promise<void>((r) => (left = r)));
|
|
468
|
+
|
|
469
|
+
const leaving = this.#leave(wasConnected, options.signal).finally(left);
|
|
470
|
+
this.#leaving = leaving;
|
|
471
|
+
try {
|
|
472
|
+
await leaving;
|
|
473
|
+
} finally {
|
|
474
|
+
this.#leaving = undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async #leave(wasConnected: boolean, signal: AbortSignal | undefined): Promise<void> {
|
|
479
|
+
this.#logger.info("disconnecting from server");
|
|
480
|
+
|
|
481
|
+
if (wasConnected) {
|
|
482
|
+
try {
|
|
483
|
+
// On the internal path: the status already says Disconnected.
|
|
484
|
+
await this.#exec(
|
|
485
|
+
"clientdisconnect reasonmsg=Shutdown",
|
|
486
|
+
{ signal, timeout: DISCONNECT_TIMEOUT_MS },
|
|
487
|
+
true,
|
|
488
|
+
);
|
|
489
|
+
} catch {
|
|
490
|
+
// A courtesy; the connection closes regardless.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
this.#handler.close();
|
|
495
|
+
this.#cmdTrack.reset(new NotConnectedError());
|
|
496
|
+
// Announced here; #handleConnectionClosed stays quiet once the status is
|
|
497
|
+
// already Disconnected, so listeners hear about it once.
|
|
498
|
+
await this.#announceDisconnected(undefined);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `await using client = …` disconnects when the scope ends. */
|
|
502
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
503
|
+
return this.disconnect();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---- Commands ------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Send a raw command line and return the rows that answer it: the escape
|
|
510
|
+
* hatch for a command the typed functions do not cover. The line is sent as
|
|
511
|
+
* given, so its values must already be escaped and any channel password
|
|
512
|
+
* already hashed.
|
|
513
|
+
*
|
|
514
|
+
* Rejects with a NotConnectedError unless the client is connected: while it
|
|
515
|
+
* is still connecting, the session's keys may not exist yet.
|
|
516
|
+
*/
|
|
517
|
+
exec(command: string, options: CommandOptions = {}): Promise<Record<string, string>[]> {
|
|
518
|
+
return this.#exec(command, options, false);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Send a raw command line without waiting for an answer, for the few
|
|
523
|
+
* commands the server answers with nothing at all.
|
|
524
|
+
*
|
|
525
|
+
* Commands are paced by a flood throttle; aborting `signal` while this waits
|
|
526
|
+
* for its turn rejects with the signal's reason and sends nothing. Rejects
|
|
527
|
+
* with a NotConnectedError unless the client is connected.
|
|
528
|
+
*/
|
|
529
|
+
execNoWait(
|
|
530
|
+
command: string,
|
|
531
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
return this.#execNoWait(command, options.signal, false);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Whether a command may go out now. The handshake's own commands (`internal`)
|
|
538
|
+
* need only an open socket; everything else needs a connected session, so
|
|
539
|
+
* nothing a caller sends can go out under the public dummy key, or after
|
|
540
|
+
* disconnect() has begun saying goodbye.
|
|
541
|
+
*/
|
|
542
|
+
#canSend(internal: boolean): boolean {
|
|
543
|
+
return this.#handler.isOpen && (internal || this.#status === ClientStatus.Connected);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async #execNoWait(
|
|
547
|
+
command: string,
|
|
548
|
+
signal: AbortSignal | undefined,
|
|
549
|
+
internal: boolean,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
signal?.throwIfAborted();
|
|
552
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
553
|
+
await this.#throttle.wait(signal);
|
|
554
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
555
|
+
this.#sendLine(command);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Send a typed command; this is the CommandExecutor the typed commands run on.
|
|
560
|
+
*
|
|
561
|
+
* The request passes through the `commandMiddleware` first. Channel
|
|
562
|
+
* passwords are then hashed, because that is this protocol's rule: the voice
|
|
563
|
+
* protocol expects `base64(sha1(password))`, where ServerQuery takes the
|
|
564
|
+
* password itself.
|
|
565
|
+
*/
|
|
566
|
+
send(request: CommandRequest, options: CommandOptions = {}): Promise<Record<string, string>[]> {
|
|
567
|
+
return this.#send(request, options);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#sendLine(line: string): void {
|
|
571
|
+
const hook = sentLineHook;
|
|
572
|
+
if (hook) hook(this, line);
|
|
573
|
+
this.#handler.sendPacket(PacketType.Command, Buffer.from(line), 0);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#exec(
|
|
577
|
+
command: string,
|
|
578
|
+
options: CommandOptions,
|
|
579
|
+
internal: boolean,
|
|
580
|
+
): Promise<Record<string, string>[]> {
|
|
581
|
+
if (!commandChannel.hasSubscribers) return this.#execUntraced(command, options, internal);
|
|
582
|
+
const trace: CommandTrace = { command: parseCommand(command)?.name ?? "", client: this };
|
|
583
|
+
return commandChannel.tracePromise(() => this.#execUntraced(command, options, internal), trace);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async #execUntraced(
|
|
587
|
+
command: string,
|
|
588
|
+
options: CommandOptions,
|
|
589
|
+
internal: boolean,
|
|
590
|
+
): Promise<Record<string, string>[]> {
|
|
591
|
+
const { signal, timeout = DEFAULT_COMMAND_TIMEOUT_MS } = options;
|
|
592
|
+
signal?.throwIfAborted();
|
|
593
|
+
// A command sent on no socket used to vanish and surface 10 seconds later
|
|
594
|
+
// as a timeout, which reads like a slow server rather than a usage error.
|
|
595
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
596
|
+
|
|
597
|
+
// Which notifications answer this command, and — for answers that arrive
|
|
598
|
+
// after the `error` — the request field they echo back.
|
|
599
|
+
const parsed = parseCommand(command);
|
|
600
|
+
const spec = responseSpec(parsed?.name ?? "");
|
|
601
|
+
const key = spec?.correlated?.key;
|
|
602
|
+
const correlation = key === undefined ? undefined : parsed?.params[key];
|
|
603
|
+
|
|
604
|
+
const [rc, answered] = this.#cmdTrack.register(spec, correlation);
|
|
605
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
606
|
+
let onAbort: (() => void) | undefined;
|
|
607
|
+
// Started before the throttle: the timeout bounds the whole command, its
|
|
608
|
+
// wait for a turn included, and so does the signal.
|
|
609
|
+
const givenUp = new Promise<never>((_, reject) => {
|
|
610
|
+
timer = setTimeout(() => reject(new CommandTimeoutError(parsed?.name ?? "")), timeout);
|
|
611
|
+
onAbort = () => reject(abortReason(signal!));
|
|
612
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
613
|
+
});
|
|
614
|
+
givenUp.catch(() => {});
|
|
615
|
+
const throttleAbort = new AbortController();
|
|
616
|
+
try {
|
|
617
|
+
const withRc = appendReturnCode(command, rc);
|
|
618
|
+
await Promise.race([this.#throttle.wait(throttleAbort.signal), givenUp]);
|
|
619
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
620
|
+
this.#sendLine(withRc);
|
|
621
|
+
|
|
622
|
+
const result = await Promise.race([answered, givenUp]);
|
|
623
|
+
if (result.err) throw result.err;
|
|
624
|
+
return result.data;
|
|
625
|
+
} finally {
|
|
626
|
+
// A command given up while it waited for its turn gives the turn back.
|
|
627
|
+
throttleAbort.abort();
|
|
628
|
+
// Cleared, not left to fire: a pending timer keeps the process alive for
|
|
629
|
+
// the full timeout after the last command has long been answered.
|
|
630
|
+
clearTimeout(timer);
|
|
631
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
632
|
+
this.#cmdTrack.unregister(rc);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---- Events --------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Listen for an event. Returns a function that removes the listener.
|
|
640
|
+
*
|
|
641
|
+
* A listener that throws does not stop the others: the rest of the event's
|
|
642
|
+
* listeners, and the events after it, are still delivered, and the exception
|
|
643
|
+
* is rethrown on its own, from a microtask, so it surfaces as an
|
|
644
|
+
* `uncaughtException` just as an EventEmitter listener's would. An async
|
|
645
|
+
* listener's rejection is not caught, and surfaces as an unhandled
|
|
646
|
+
* rejection.
|
|
647
|
+
*/
|
|
648
|
+
on<K extends keyof EventMap>(event: K, listener: Listener<K>): Unsubscribe {
|
|
649
|
+
const subscription: Subscription = {
|
|
650
|
+
listener: listener as (payload: unknown) => void,
|
|
651
|
+
removed: false,
|
|
652
|
+
};
|
|
653
|
+
// A new array rather than an edit in place: a delivery may be iterating
|
|
654
|
+
// over the old one.
|
|
655
|
+
this.#listeners.set(event, [...(this.#listeners.get(event) ?? []), subscription]);
|
|
656
|
+
|
|
657
|
+
const unsubscribe = (): void => {
|
|
658
|
+
if (subscription.removed) return;
|
|
659
|
+
subscription.removed = true;
|
|
660
|
+
const list = this.#listeners.get(event) ?? [];
|
|
661
|
+
this.#listeners.set(
|
|
662
|
+
event,
|
|
663
|
+
list.filter((s) => s !== subscription),
|
|
664
|
+
);
|
|
665
|
+
};
|
|
666
|
+
return Object.assign(unsubscribe, { [Symbol.dispose]: unsubscribe });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Listen for the next occurrence of an event only. */
|
|
670
|
+
once<K extends keyof EventMap>(event: K, listener: Listener<K>): Unsubscribe {
|
|
671
|
+
const unsubscribe = this.on(event, ((payload: EventMap[K]) => {
|
|
672
|
+
unsubscribe();
|
|
673
|
+
(listener as (payload: EventMap[K]) => void)(payload);
|
|
674
|
+
}) as Listener<K>);
|
|
675
|
+
return unsubscribe;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* An event as an async iterator, for `for await`:
|
|
680
|
+
*
|
|
681
|
+
* ```ts
|
|
682
|
+
* for await (const message of client.events("textMessage", { signal })) { … }
|
|
683
|
+
* ```
|
|
684
|
+
*
|
|
685
|
+
* Events that arrive while the loop body runs are buffered, so none is
|
|
686
|
+
* missed — except voice, of which at most 256 frames are buffered; the rest
|
|
687
|
+
* are dropped, as for a listener that falls behind. Iteration ends when
|
|
688
|
+
* `signal` aborts, when the loop exits early (`break`, `return`, a throw),
|
|
689
|
+
* and — for every event but `disconnected` — when the client disconnects.
|
|
690
|
+
* An abort stops listening at once and discards whatever is buffered.
|
|
691
|
+
*/
|
|
692
|
+
events<K extends keyof EventMap>(
|
|
693
|
+
event: K,
|
|
694
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
695
|
+
): AsyncIterableIterator<EventMap[K]> {
|
|
696
|
+
const { signal } = options;
|
|
697
|
+
const buffered: Array<EventMap[K]> = [];
|
|
698
|
+
let wake: (() => void) | undefined;
|
|
699
|
+
let ended = false;
|
|
700
|
+
|
|
701
|
+
const stopListening = this.on(event, ((payload: EventMap[K]) => {
|
|
702
|
+
if (ended) return;
|
|
703
|
+
if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
|
|
704
|
+
this.#dropVoice();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
buffered.push(payload);
|
|
708
|
+
wake?.();
|
|
709
|
+
}) as Listener<K>);
|
|
710
|
+
// Ends after what is already buffered, which arrived before the disconnect.
|
|
711
|
+
const stopOnDisconnect =
|
|
712
|
+
event === "disconnected"
|
|
713
|
+
? undefined
|
|
714
|
+
: this.on("disconnected", () => {
|
|
715
|
+
ended = true;
|
|
716
|
+
stopListening();
|
|
717
|
+
wake?.();
|
|
718
|
+
});
|
|
719
|
+
const cleanup = (): void => {
|
|
720
|
+
ended = true;
|
|
721
|
+
stopListening();
|
|
722
|
+
stopOnDisconnect?.();
|
|
723
|
+
signal?.removeEventListener("abort", abort);
|
|
724
|
+
wake?.();
|
|
725
|
+
};
|
|
726
|
+
const abort = (): void => {
|
|
727
|
+
buffered.length = 0;
|
|
728
|
+
cleanup();
|
|
729
|
+
};
|
|
730
|
+
if (signal?.aborted) abort();
|
|
731
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
732
|
+
|
|
733
|
+
const iterator: AsyncIterableIterator<EventMap[K]> = {
|
|
734
|
+
async next(): Promise<IteratorResult<EventMap[K]>> {
|
|
735
|
+
while (buffered.length === 0 && !ended) {
|
|
736
|
+
await new Promise<void>((resolve) => (wake = resolve));
|
|
737
|
+
wake = undefined;
|
|
738
|
+
}
|
|
739
|
+
if (buffered.length > 0) return { value: buffered.shift()!, done: false };
|
|
740
|
+
cleanup();
|
|
741
|
+
return { value: undefined, done: true };
|
|
742
|
+
},
|
|
743
|
+
async return(): Promise<IteratorResult<EventMap[K]>> {
|
|
744
|
+
buffered.length = 0;
|
|
745
|
+
cleanup();
|
|
746
|
+
return { value: undefined, done: true };
|
|
747
|
+
},
|
|
748
|
+
[Symbol.asyncIterator]() {
|
|
749
|
+
return iterator;
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
return iterator;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ---- API shorthand -------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
/** This client's id on the server, assigned in the handshake; 0 before it. */
|
|
758
|
+
get clientID(): ClientID {
|
|
759
|
+
return asClientID(this.#clid);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* What the connected server is: version, generation, and the uid hash it
|
|
764
|
+
* uses. Read from `initserver`; before connecting the family is `unknown`
|
|
765
|
+
* and the uid hash SHA-1.
|
|
766
|
+
*/
|
|
767
|
+
get serverCapabilities(): ServerCapabilities {
|
|
768
|
+
return this.#capabilities;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Our own `client_unique_identifier`, hashed with the algorithm the connected
|
|
773
|
+
* server uses.
|
|
774
|
+
*
|
|
775
|
+
* TS3 uses SHA-1 and TS6 SHA-256, so this reads the algorithm learned during
|
|
776
|
+
* initserver rather than assuming one. Comparing a hard-coded SHA-1 UID to a
|
|
777
|
+
* TS6 server silently never matches.
|
|
778
|
+
*/
|
|
779
|
+
get uid(): UID {
|
|
780
|
+
return this.#identity.uid(this.#capabilities.uidAlgorithm);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The channel this client is in, as it last learned; 0 before connecting. */
|
|
784
|
+
get channelID(): ChannelID {
|
|
785
|
+
return this.#clients.get(this.#clid)?.channelID ?? asChannelID(0n);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Send Opus voice directly to specific clients, bypassing channel routing.
|
|
790
|
+
* Throws a NotConnectedError unless the client is connected.
|
|
791
|
+
*/
|
|
792
|
+
sendWhisper(data: Uint8Array, targetClientIDs: readonly ClientID[], codec: Codec): void {
|
|
793
|
+
if (!this.#canSend(false)) throw new NotConnectedError();
|
|
794
|
+
this.#handler.sendWhisperPacket(data, [...targetClientIDs], codec);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Send one encoded audio frame to the channel this client is in. Throws a
|
|
799
|
+
* NotConnectedError unless the client is connected.
|
|
800
|
+
*/
|
|
801
|
+
sendVoice(data: Uint8Array, codec: Codec): void {
|
|
802
|
+
if (!this.#canSend(false)) throw new NotConnectedError();
|
|
803
|
+
this.#handler.sendVoicePacket(data, codec);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ---- File Transfer -------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Upload `data` over a negotiated transfer, through the client's
|
|
810
|
+
* `fileTransferSocketFactory` if it has one.
|
|
811
|
+
*/
|
|
812
|
+
uploadFileData(
|
|
813
|
+
host: string,
|
|
814
|
+
info: FileUploadInfo,
|
|
815
|
+
data: Readable,
|
|
816
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
817
|
+
): Promise<void> {
|
|
818
|
+
return uploadFileData(host, info, data, {
|
|
819
|
+
...options,
|
|
820
|
+
socketFactory: this.#fileTransferSocketFactory,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Download a negotiated transfer into `dest`, through the client's
|
|
826
|
+
* `fileTransferSocketFactory` if it has one.
|
|
827
|
+
*/
|
|
828
|
+
downloadFileData(
|
|
829
|
+
host: string,
|
|
830
|
+
info: FileDownloadInfo,
|
|
831
|
+
dest: Writable,
|
|
832
|
+
options: { readonly signal?: AbortSignal | undefined } = {},
|
|
833
|
+
): Promise<void> {
|
|
834
|
+
return downloadFileData(host, info, dest, {
|
|
835
|
+
...options,
|
|
836
|
+
socketFactory: this.#fileTransferSocketFactory,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---- Internal (package-visible) ------------------------------------------
|
|
841
|
+
|
|
842
|
+
#markConnected(): void {
|
|
843
|
+
// Only the attempt in progress can complete: never one that disconnect()
|
|
844
|
+
// or a failure has already ended.
|
|
845
|
+
if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
|
|
846
|
+
this.#status = ClientStatus.Connected;
|
|
847
|
+
this.#attempt.resolve();
|
|
848
|
+
this.#emit("connected", undefined);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ---- Private -------------------------------------------------------------
|
|
852
|
+
|
|
853
|
+
#newHandler(): PacketHandler {
|
|
854
|
+
const handler = new PacketHandler(this.#crypt, this.#logger, this.#socketFactory);
|
|
855
|
+
// Each callback checks that its handler is still the current one: a
|
|
856
|
+
// packet or close reported late by one a later connect() already replaced
|
|
857
|
+
// must not reach, or tear down, the new connection.
|
|
858
|
+
handler.onPacket = (p) => {
|
|
859
|
+
if (handler === this.#handler) this.#handlePacket(p);
|
|
860
|
+
};
|
|
861
|
+
handler.onClosed = (err) => {
|
|
862
|
+
if (handler === this.#handler) this.#handleConnectionClosed(err);
|
|
863
|
+
};
|
|
864
|
+
handler.onReceiveError = (error) => {
|
|
865
|
+
if (handler === this.#handler) this.#handleReceiveError(error);
|
|
866
|
+
};
|
|
867
|
+
return handler;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
#resetForConnect(): void {
|
|
871
|
+
this.#handler.close();
|
|
872
|
+
this.#crypt = new Crypt(this.#identity);
|
|
873
|
+
this.#handler = this.#newHandler();
|
|
874
|
+
this.#attempt = newConnectAttempt();
|
|
875
|
+
this.#cmdTrack.reset(new NotConnectedError());
|
|
876
|
+
this.#clients.clear();
|
|
877
|
+
this.#clid = 0;
|
|
878
|
+
this.#capabilities = unknownServerCapabilities();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async #resolveAddr(signal: AbortSignal): Promise<string> {
|
|
882
|
+
// Parsed, not tested for a colon: an IPv6 literal has colons and no port.
|
|
883
|
+
const { host, port } = splitHostPort(this.#addr);
|
|
884
|
+
const fallback = joinHostPort(host, port);
|
|
885
|
+
// Every resolver, a custom one too, gets the same budget: past it the
|
|
886
|
+
// address is used as given. A timer the tests can control, not
|
|
887
|
+
// AbortSignal.timeout().
|
|
888
|
+
const budget = new AbortController();
|
|
889
|
+
const timer = setTimeout(
|
|
890
|
+
() => budget.abort(new TeamspeakError("address resolution took too long")),
|
|
891
|
+
RESOLVE_BUDGET_MS,
|
|
892
|
+
);
|
|
893
|
+
const lookup = AbortSignal.any([signal, budget.signal]);
|
|
894
|
+
try {
|
|
895
|
+
// Raced against the signal: a resolver that ignores it must still not
|
|
896
|
+
// hold up an attempt that has been given up.
|
|
897
|
+
const resolved = await abortable(
|
|
898
|
+
this.#resolver.resolve(this.#addr, { signal: lookup }),
|
|
899
|
+
lookup,
|
|
900
|
+
);
|
|
901
|
+
return resolved[0]?.addr ?? fallback;
|
|
902
|
+
} catch (error) {
|
|
903
|
+
signal.throwIfAborted();
|
|
904
|
+
this.#logger.warn("address resolution failed, falling back to direct", {
|
|
905
|
+
error: String(budget.signal.aborted ? budget.signal.reason : error),
|
|
906
|
+
});
|
|
907
|
+
return fallback;
|
|
908
|
+
} finally {
|
|
909
|
+
clearTimeout(timer);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* A datagram from the server could not be handled. During the handshake that
|
|
915
|
+
* is the connection failing, reported as a CryptoInitError with the cause;
|
|
916
|
+
* once connected, one bad packet is not worth the session, so it is dropped.
|
|
917
|
+
*/
|
|
918
|
+
#handleReceiveError(error: unknown): void {
|
|
919
|
+
if (this.#status !== ClientStatus.Connected) {
|
|
920
|
+
this.#failConnection(
|
|
921
|
+
error instanceof CryptoInitError
|
|
922
|
+
? error
|
|
923
|
+
: new CryptoInitError("the handshake failed on a malformed packet", { cause: error }),
|
|
924
|
+
);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
this.#logger.debug("dropped a malformed packet", { error: String(error) });
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
#handlePacket(p: Packet): void {
|
|
931
|
+
const pType = p.typeFlagged & 0x0f;
|
|
932
|
+
if (pType === 8 /* Init1 */) {
|
|
933
|
+
if (this.#crypt.cryptoInitComplete) return;
|
|
934
|
+
const response = processInit1(this.#crypt, p.data);
|
|
935
|
+
if (response) {
|
|
936
|
+
this.#handler.sendPacket(PacketType.Init1, response, 0);
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if ((pType === 0 /* Voice */ || pType === 1) /* VoiceWhisper */ && p.data.length > 5) {
|
|
942
|
+
this.#handleVoicePacket(p.data);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if ((pType === 2 /* Command */ || pType === 3) /* CommandLow */ && p.data.length > 0) {
|
|
947
|
+
this.#handleCommandLines(Buffer.from(p.data).toString("utf8"));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Parse an incoming S2C voice packet.
|
|
953
|
+
* Format: [VId: u16] [CId: u16] [Codec: u8] [Data: var]
|
|
954
|
+
*/
|
|
955
|
+
#handleVoicePacket(payload: Uint8Array): void {
|
|
956
|
+
// Audio arrives fifty times a second per speaker; build no event nobody
|
|
957
|
+
// will see.
|
|
958
|
+
if (!this.#listeners.get("voiceData")?.length && this.#eventMiddlewares.length === 0) return;
|
|
959
|
+
if (this.#queue.length > MAX_PENDING_DELIVERIES) {
|
|
960
|
+
this.#dropVoice();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
965
|
+
const clientId = view.getUint16(2, false);
|
|
966
|
+
if (clientId === this.#clid) return;
|
|
967
|
+
|
|
968
|
+
// Copied: the packet's buffer is reused once this returns, and a listener
|
|
969
|
+
// runs later.
|
|
970
|
+
const data = payload.slice(5);
|
|
971
|
+
this.#emitServerEvent({
|
|
972
|
+
name: "voiceData",
|
|
973
|
+
payload: { clientID: asClientID(clientId), codec: payload[4]!, data },
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
#handleCommandLines(s: string): void {
|
|
978
|
+
if (!s) return;
|
|
979
|
+
const lines = s.split("\0").join("\n").split("\n");
|
|
980
|
+
for (const line of lines) {
|
|
981
|
+
const trimmed = line.replace(/\r$/, "");
|
|
982
|
+
if (!trimmed) continue;
|
|
983
|
+
for (const row of splitCommandRows(trimmed)) {
|
|
984
|
+
this.#handleCommandStr(row);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#handleCommandStr(s: string): void {
|
|
990
|
+
const cmd = parseCommand(s);
|
|
991
|
+
if (!cmd) return;
|
|
992
|
+
// Before crypto init a packet needs only the public dummy key, so anyone
|
|
993
|
+
// on the path could have sent it: nothing but the handshake's own steps,
|
|
994
|
+
// and an error that may end the attempt, is taken then. A notification
|
|
995
|
+
// would be a forged event, a row a forged answer.
|
|
996
|
+
if (!this.#crypt.cryptoInitComplete && !PRE_INIT_COMMANDS.has(cmd.name)) {
|
|
997
|
+
this.#logger.debug("dropped a command that arrived before crypto init", {
|
|
998
|
+
command: cmd.name,
|
|
999
|
+
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (packetChannel.hasSubscribers) packetChannel.publish({ command: cmd.name, client: this });
|
|
1003
|
+
|
|
1004
|
+
// No name means a response row: the server sends data without echoing the
|
|
1005
|
+
// command, so the row belongs to whichever command is awaiting a result.
|
|
1006
|
+
if (cmd.name === "") {
|
|
1007
|
+
if (Object.keys(cmd.params).length > 0) this.#cmdTrack.buffer(cmd.params);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// `channellistfinished` ends the server's welcome sequence. It carries no
|
|
1012
|
+
// data of its own, so it is the only reliable marker: the guard used to
|
|
1013
|
+
// trip on our own notifycliententerview, which the server sends *in the
|
|
1014
|
+
// middle* of the sequence — the channellist rows after it were then
|
|
1015
|
+
// buffered as if they were the response to the caller's first command,
|
|
1016
|
+
// which is how a channellist could come back with duplicate and empty rows.
|
|
1017
|
+
if (cmd.name === "channellistfinished") {
|
|
1018
|
+
this.#cmdTrack.signalWelcomeComplete();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (cmd.name.startsWith("notify")) {
|
|
1023
|
+
// Some commands are answered by a notification instead of data rows;
|
|
1024
|
+
// it still goes on to be dispatched as an event, since the same
|
|
1025
|
+
// notification also arrives unprompted.
|
|
1026
|
+
if (this.#cmdTrack.expects(cmd.name)) {
|
|
1027
|
+
this.#cmdTrack.buffer(cmd.params, cmd.name);
|
|
1028
|
+
this.#cmdTrack.offerCorrelated(cmd.name, cmd.params);
|
|
1029
|
+
}
|
|
1030
|
+
const result = handleNotification(cmd, this.#clid, this.#clients, this.#nickname);
|
|
1031
|
+
this.#processNotificationResult(result);
|
|
1032
|
+
if (this.#listeners.get("notification")?.length || this.#eventMiddlewares.length > 0) {
|
|
1033
|
+
this.#emitServerEvent({
|
|
1034
|
+
name: "notification",
|
|
1035
|
+
payload: { name: cmd.name, params: { ...cmd.params } },
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
switch (cmd.name) {
|
|
1042
|
+
// The handshake's steps happen once: repeated after crypto init, they
|
|
1043
|
+
// could only re-key or re-announce a session that is already set up.
|
|
1044
|
+
case "clientinitiv":
|
|
1045
|
+
if (!this.#crypt.cryptoInitComplete) handleHandshakeInitIV(this.#internals, cmd.params);
|
|
1046
|
+
break;
|
|
1047
|
+
case "initivexpand2":
|
|
1048
|
+
if (!this.#crypt.cryptoInitComplete) handleHandshakeExpand2(this.#internals, cmd.params);
|
|
1049
|
+
break;
|
|
1050
|
+
case "initserver":
|
|
1051
|
+
// Only on a session with its keys: before crypto init a packet needs
|
|
1052
|
+
// nothing but the public dummy key, and an initserver taken then would
|
|
1053
|
+
// report a connection whose next command — clientinit with its password
|
|
1054
|
+
// hashes among them — goes out under that dummy key.
|
|
1055
|
+
if (this.#status === ClientStatus.Connecting && this.#crypt.cryptoInitComplete) {
|
|
1056
|
+
handleInitServer(this.#internals, cmd.params);
|
|
1057
|
+
}
|
|
1058
|
+
break;
|
|
1059
|
+
case "error":
|
|
1060
|
+
this.#handleError(cmd.params);
|
|
1061
|
+
break;
|
|
1062
|
+
default:
|
|
1063
|
+
// A named command the SDK does not model. Held under its own name, so
|
|
1064
|
+
// it answers a command only if that command declared it — not merely by
|
|
1065
|
+
// arriving while one is in flight.
|
|
1066
|
+
this.#cmdTrack.buffer(cmd.params, cmd.name);
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#handleError(params: Record<string, string>): void {
|
|
1072
|
+
const { err, rc } = parseServerError(params);
|
|
1073
|
+
if (rc !== null) {
|
|
1074
|
+
this.#cmdTrack.resolve(rc, err);
|
|
1075
|
+
} else {
|
|
1076
|
+
// No return_code: server is acknowledging an unsolicited command
|
|
1077
|
+
// (welcome-sequence channellist, no-RC commands like clientupdate, etc.).
|
|
1078
|
+
// Discard any rows buffered since the last resolved command.
|
|
1079
|
+
this.#cmdTrack.discardBuffer();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// A server error before the welcome sequence finished is the connection
|
|
1083
|
+
// failure itself — a rejected password, or an invalid clientinit parameter.
|
|
1084
|
+
// Report it as the cause instead of leaving the caller with a bare timeout.
|
|
1085
|
+
if (err && this.#status !== ClientStatus.Connected) {
|
|
1086
|
+
this.#failConnection(err);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Only a connected session gets here, so only after crypto init: an error
|
|
1091
|
+
// under the public dummy key can fail an attempt, never stop the client.
|
|
1092
|
+
if (err?.code === ErrorCode.ConnectFailedBanned) {
|
|
1093
|
+
this.#logger.warn("banned by the server, closing the connection");
|
|
1094
|
+
setImmediate(() => this.disconnect().catch(() => {}));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
#processNotificationResult(result: import("./notifications.js").NotificationResult): void {
|
|
1099
|
+
switch (result.kind) {
|
|
1100
|
+
case "clientEnter": {
|
|
1101
|
+
const info = result.info;
|
|
1102
|
+
// Identify self by the clid initserver already assigned rather than by
|
|
1103
|
+
// nickname: any other client whose nickname happens to be "<our
|
|
1104
|
+
// nick><digits>" — which is how the server renames a client whose
|
|
1105
|
+
// nickname was taken — would otherwise be mistaken for us, poisoning
|
|
1106
|
+
// every outgoing packet header with a foreign clientID until the server
|
|
1107
|
+
// drops the connection for command-resend timeout.
|
|
1108
|
+
//
|
|
1109
|
+
// This no longer marks the end of the welcome sequence: the server
|
|
1110
|
+
// sends more welcome data after it, and `channellistfinished` is the
|
|
1111
|
+
// real terminator.
|
|
1112
|
+
if (info.id === this.#clid) {
|
|
1113
|
+
// Carries the rename the server applied if our nickname was taken.
|
|
1114
|
+
this.#nickname = info.nickname;
|
|
1115
|
+
} else if (this.#clid === 0 && isAutoNicknameMatch(this.#nickname, info.nickname)) {
|
|
1116
|
+
// initserver carried no aclid, so the welcome enter is the only way
|
|
1117
|
+
// to learn our clid. Gated on clid still being unknown so a later
|
|
1118
|
+
// lookalike can never hijack an established session.
|
|
1119
|
+
this.#clid = info.id;
|
|
1120
|
+
this.#handler.setClientID(info.id);
|
|
1121
|
+
this.#nickname = info.nickname;
|
|
1122
|
+
}
|
|
1123
|
+
this.#emitServerEvent({ name: "clientEnter", payload: info });
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
case "clientLeave": {
|
|
1127
|
+
const event = result.event;
|
|
1128
|
+
this.#emitServerEvent({ name: "clientLeave", payload: event });
|
|
1129
|
+
if (!result.isSelf) break;
|
|
1130
|
+
if (event.reason === Reason.KickServer || event.reason === Reason.KickServerBan) {
|
|
1131
|
+
this.#emit("kicked", kickedEvent(event));
|
|
1132
|
+
}
|
|
1133
|
+
this.#removedByServer(
|
|
1134
|
+
new RemovedError(event.reason, event.reasonMessage, event.invokerName),
|
|
1135
|
+
);
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
case "clientMoved":
|
|
1139
|
+
this.#emitServerEvent({ name: "clientMoved", payload: result.event });
|
|
1140
|
+
// A channel kick moves the client to the default channel; it arrives
|
|
1141
|
+
// as a move, not as leaving view, which is where it used to be looked
|
|
1142
|
+
// for.
|
|
1143
|
+
if (result.event.clientID === this.#clid && result.event.reason === Reason.KickChannel) {
|
|
1144
|
+
this.#emit(
|
|
1145
|
+
"kicked",
|
|
1146
|
+
kickedEvent({ ...result.event, reasonMessage: result.reasonMessage }),
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
break;
|
|
1150
|
+
case "textMessage":
|
|
1151
|
+
this.#emitServerEvent({ name: "textMessage", payload: result.message });
|
|
1152
|
+
break;
|
|
1153
|
+
case "poked":
|
|
1154
|
+
this.#emitServerEvent({ name: "poked", payload: result.event });
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* End a session the server has ended. The server stops answering a client it
|
|
1161
|
+
* has removed, so waiting for the transport to notice reported the end a
|
|
1162
|
+
* minute late, without its cause, and with the client claiming to be
|
|
1163
|
+
* connected meanwhile.
|
|
1164
|
+
*/
|
|
1165
|
+
#removedByServer(cause: Error): void {
|
|
1166
|
+
this.#handleConnectionClosed(cause);
|
|
1167
|
+
this.#handler.close();
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Count a dropped voice delivery, and report the drops now and then: at most
|
|
1172
|
+
* once every ten seconds, with how many were dropped since the last report.
|
|
1173
|
+
*/
|
|
1174
|
+
#dropVoice(): void {
|
|
1175
|
+
this.#voiceDropped++;
|
|
1176
|
+
const now = Date.now();
|
|
1177
|
+
if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
|
|
1178
|
+
this.#voiceDropWarnedAt = now;
|
|
1179
|
+
// The count since the last warning, so each one says how bad it is now.
|
|
1180
|
+
this.#logger.warn("voice dropped: listeners are not keeping up", {
|
|
1181
|
+
dropped: this.#voiceDropped,
|
|
1182
|
+
});
|
|
1183
|
+
this.#voiceDropped = 0;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Queue a server event, through the event middleware. */
|
|
1187
|
+
#emitServerEvent(event: ServerEvent): void {
|
|
1188
|
+
this.#enqueue(() => this.#eventChain(event));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Queue `disconnected`; the promise settles once it has been delivered, and
|
|
1193
|
+
* a connect() waits for it until then.
|
|
1194
|
+
*/
|
|
1195
|
+
#announceDisconnected(cause: Error | undefined): Promise<void> {
|
|
1196
|
+
const delivered = new Promise<void>((resolve) =>
|
|
1197
|
+
this.#enqueue(() => {
|
|
1198
|
+
try {
|
|
1199
|
+
this.#deliver("disconnected", cause);
|
|
1200
|
+
} finally {
|
|
1201
|
+
resolve();
|
|
1202
|
+
}
|
|
1203
|
+
}),
|
|
1204
|
+
);
|
|
1205
|
+
this.#disconnectedDelivered = delivered;
|
|
1206
|
+
void delivered.then(() => {
|
|
1207
|
+
if (this.#disconnectedDelivered === delivered) this.#disconnectedDelivered = undefined;
|
|
1208
|
+
});
|
|
1209
|
+
return delivered;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Queue a lifecycle event, which middleware does not see. */
|
|
1213
|
+
#emit<K extends Exclude<keyof EventMap, ServerEventName>>(event: K, payload: EventMap[K]): void {
|
|
1214
|
+
this.#enqueue(() => this.#deliver(event, payload));
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
#deliver<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
|
|
1218
|
+
for (const subscription of this.#listeners.get(event) ?? []) {
|
|
1219
|
+
// Checked at delivery: a listener removed while its event was queued
|
|
1220
|
+
// does not get it.
|
|
1221
|
+
if (subscription.removed) continue;
|
|
1222
|
+
try {
|
|
1223
|
+
subscription.listener(payload);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
// Rethrown on its own, as an uncaught exception, so it surfaces as one
|
|
1226
|
+
// from an EventEmitter listener would, and the listeners after it
|
|
1227
|
+
// still get the event.
|
|
1228
|
+
queueMicrotask(() => {
|
|
1229
|
+
throw error;
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Run deliveries one at a time, in order, once the packet being handled is
|
|
1237
|
+
* done with. A listener that throws does so as an uncaught exception, as it
|
|
1238
|
+
* would from an EventEmitter, and the listeners and deliveries behind it
|
|
1239
|
+
* still run.
|
|
1240
|
+
*/
|
|
1241
|
+
#enqueue(delivery: () => void): void {
|
|
1242
|
+
this.#queue.push(delivery);
|
|
1243
|
+
if (this.#draining) return;
|
|
1244
|
+
this.#draining = true;
|
|
1245
|
+
setImmediate(this.#drain);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
#drain = (): void => {
|
|
1249
|
+
try {
|
|
1250
|
+
while (this.#queue.length > 0) this.#queue.shift()!();
|
|
1251
|
+
} finally {
|
|
1252
|
+
if (this.#queue.length > 0) setImmediate(this.#drain);
|
|
1253
|
+
else this.#draining = false;
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
#handleConnectionClosed(err: Error | null): void {
|
|
1258
|
+
if (this.#status === ClientStatus.Disconnected) return;
|
|
1259
|
+
// Nothing in flight can be answered any more; failing it now, with the
|
|
1260
|
+
// reason, beats every caller timing out one by one.
|
|
1261
|
+
this.#cmdTrack.reset(new NotConnectedError(err ? { cause: err } : undefined));
|
|
1262
|
+
if (this.#status !== ClientStatus.Connected) {
|
|
1263
|
+
// The socket closed before the handshake finished, so this is a failed
|
|
1264
|
+
// connection attempt rather than a disconnect of a live session.
|
|
1265
|
+
this.#failConnection(
|
|
1266
|
+
err ?? new TeamspeakError("connection closed before the TeamSpeak handshake completed"),
|
|
1267
|
+
);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
this.#status = ClientStatus.Disconnected;
|
|
1271
|
+
// Never undefined for a drop: undefined means disconnect() only.
|
|
1272
|
+
const cause = err ?? new TeamspeakError("connection closed");
|
|
1273
|
+
this.#lastDrop = cause;
|
|
1274
|
+
|
|
1275
|
+
// Announced first, then retried: a caller watching `disconnected` should
|
|
1276
|
+
// hear about the drop even when a reconnect is already on its way, because
|
|
1277
|
+
// a silent recovery hides an outage.
|
|
1278
|
+
void this.#announceDisconnected(cause);
|
|
1279
|
+
this.#scheduleReconnect(cause).catch((error: unknown) => {
|
|
1280
|
+
this.#logger.error("reconnecting failed", { error: String(error) });
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Retry a dropped connection according to the reconnect policy.
|
|
1286
|
+
*
|
|
1287
|
+
* Never runs after an explicit disconnect, and never more than one at a time:
|
|
1288
|
+
* two failures in quick succession would otherwise start competing loops, and
|
|
1289
|
+
* a loop shorter than the server's antiflood window bans the IP rather than
|
|
1290
|
+
* reconnecting it. A drop while the loop runs is the loop's to retry: it
|
|
1291
|
+
* ends only once its attempt is still connected when it resumes.
|
|
1292
|
+
*/
|
|
1293
|
+
async #scheduleReconnect(err: Error): Promise<void> {
|
|
1294
|
+
const options = this.#reconnectOptions;
|
|
1295
|
+
if (options === undefined || this.#stopped || this.#reconnecting) return;
|
|
1296
|
+
|
|
1297
|
+
const policy = resolveBackoffPolicy(options);
|
|
1298
|
+
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
|
1299
|
+
|
|
1300
|
+
this.#reconnecting = true;
|
|
1301
|
+
|
|
1302
|
+
const stop = new AbortController();
|
|
1303
|
+
this.#stopReconnecting = stop;
|
|
1304
|
+
try {
|
|
1305
|
+
for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
|
|
1306
|
+
if (this.#stopped) return;
|
|
1307
|
+
let retry: boolean;
|
|
1308
|
+
try {
|
|
1309
|
+
retry = shouldRetry(err, attempt);
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (!retry) {
|
|
1315
|
+
this.#logger.info("not retrying", { attempt, reason: err.message });
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const delay = backoffDelay(policy, attempt);
|
|
1320
|
+
this.#logger.info("reconnecting", { attempt, delayMs: delay });
|
|
1321
|
+
// A timer that keeps the process alive: an application that does
|
|
1322
|
+
// nothing but stay connected would otherwise exit during the wait.
|
|
1323
|
+
await sleep(delay, stop.signal);
|
|
1324
|
+
if (this.#stopped) return;
|
|
1325
|
+
// Connected, or connecting, by hand during the wait: nothing is left to
|
|
1326
|
+
// retry, and an attempt now would only fail with AlreadyConnectedError.
|
|
1327
|
+
if (this.#status !== ClientStatus.Disconnected) {
|
|
1328
|
+
this.#logger.info("reconnect loop ended: connected by other means", { attempt });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
try {
|
|
1333
|
+
await this.connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) });
|
|
1334
|
+
// Read afresh: the status may have changed while connect() resolved.
|
|
1335
|
+
if (this.status === ClientStatus.Connected) {
|
|
1336
|
+
this.#logger.info("reconnected", { attempt });
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
// The new session dropped before this loop resumed — initserver and
|
|
1340
|
+
// the server's goodbye in one datagram, say. That drop found this
|
|
1341
|
+
// loop running and left the retrying to it.
|
|
1342
|
+
err = this.#lastDrop ?? new TeamspeakError("connection closed");
|
|
1343
|
+
} catch (retryErr) {
|
|
1344
|
+
if (retryErr instanceof AlreadyConnectedError) return;
|
|
1345
|
+
err = retryErr instanceof Error ? retryErr : new TeamspeakError(String(retryErr));
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
|
|
1349
|
+
} finally {
|
|
1350
|
+
this.#reconnecting = false;
|
|
1351
|
+
if (this.#stopReconnecting === stop) this.#stopReconnecting = undefined;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* A connection attempt has failed for good: settle it with the cause, and
|
|
1357
|
+
* tear the connection down. Does nothing once the handshake has completed,
|
|
1358
|
+
* so it cannot end a live session.
|
|
1359
|
+
*
|
|
1360
|
+
* Without this, a handshake the server rejects (a wrong password, say, which
|
|
1361
|
+
* the server answers with an error instead of closing the socket) surfaces
|
|
1362
|
+
* only as the caller's own timeout, and the client stays in `Connecting`
|
|
1363
|
+
* forever without ever emitting `disconnected`.
|
|
1364
|
+
*/
|
|
1365
|
+
#failConnection(error: unknown): void {
|
|
1366
|
+
if (this.#status === ClientStatus.Connected) return;
|
|
1367
|
+
this.#attempt.reject(error);
|
|
1368
|
+
|
|
1369
|
+
if (this.#status === ClientStatus.Disconnected) return;
|
|
1370
|
+
this.#status = ClientStatus.Disconnected;
|
|
1371
|
+
this.#handler.close();
|
|
1372
|
+
void this.#announceDisconnected(
|
|
1373
|
+
error instanceof Error ? error : new TeamspeakError(String(error)),
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function kickedEvent(event: {
|
|
1379
|
+
readonly reason: number;
|
|
1380
|
+
readonly reasonMessage: string;
|
|
1381
|
+
readonly invokerName: string;
|
|
1382
|
+
readonly invokerUID: UID;
|
|
1383
|
+
readonly invokerID: ClientID;
|
|
1384
|
+
}): KickedEvent {
|
|
1385
|
+
return {
|
|
1386
|
+
reason: event.reason,
|
|
1387
|
+
reasonMessage: event.reasonMessage,
|
|
1388
|
+
invokerName: event.invokerName,
|
|
1389
|
+
invokerUID: event.invokerUID,
|
|
1390
|
+
invokerID: event.invokerID,
|
|
1391
|
+
};
|
|
1392
|
+
}
|