@honeybbq/teamspeak-client 1.0.0-beta.2 → 1.0.0-beta.4
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 +61 -8
- package/dist/capabilities.d.ts +13 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/client.d.ts +17 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +9 -1
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake.d.ts.map +1 -1
- package/dist/hooks.d.ts +7 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +762 -185
- package/dist/index.js.map +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/trace.d.ts +223 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/transfer.d.ts +80 -1
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +2 -0
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/types.d.ts +17 -110
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/capabilities.ts +23 -2
- package/src/client.ts +392 -54
- package/src/crypto/crypt.ts +5 -5
- package/src/discovery/resolver.ts +56 -3
- package/src/errors.ts +92 -11
- package/src/handshake/crypt-handshake.ts +26 -5
- package/src/handshake/crypt-init2.ts +5 -5
- package/src/handshake.ts +3 -1
- package/src/hooks.ts +30 -0
- package/src/index.ts +30 -10
- package/src/notifications.ts +1 -1
- package/src/throttle.ts +2 -2
- package/src/trace.ts +238 -0
- package/src/transfer.ts +334 -25
- package/src/transport/handler.ts +13 -9
- package/src/transport/packet.ts +1 -1
- package/src/types.ts +21 -123
package/src/client.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type EventMap,
|
|
3
3
|
type ClientOptions,
|
|
4
|
+
type DatagramSocket,
|
|
5
|
+
type ResolvedAddr,
|
|
4
6
|
type EventMiddleware,
|
|
5
7
|
type AddrResolver,
|
|
6
|
-
type ClientEnterEvent,
|
|
7
|
-
type KickedEvent,
|
|
8
8
|
type Listener,
|
|
9
9
|
type ServerEvent,
|
|
10
10
|
type ServerEventName,
|
|
11
11
|
type Unsubscribe,
|
|
12
12
|
ClientStatus,
|
|
13
13
|
} from "./types.js";
|
|
14
|
+
import { ConnectPhase, type ClientTrace } from "./trace.js";
|
|
15
|
+
import { callHook } from "./hooks.js";
|
|
16
|
+
import { puzzleOf } from "./handshake/crypt-handshake.js";
|
|
14
17
|
import {
|
|
15
18
|
type ChannelID,
|
|
19
|
+
type ClientEnterEvent,
|
|
16
20
|
type ClientID,
|
|
17
21
|
type Codec,
|
|
22
|
+
type KickedEvent,
|
|
18
23
|
type CommandMiddleware,
|
|
19
24
|
type CommandOptions,
|
|
20
25
|
type UID,
|
|
@@ -26,7 +31,7 @@ import {
|
|
|
26
31
|
TeamspeakError,
|
|
27
32
|
noopLogger,
|
|
28
33
|
} from "@honeybbq/teamspeak-core";
|
|
29
|
-
import { AlreadyConnectedError,
|
|
34
|
+
import { AlreadyConnectedError, HandshakeError, RemovedError } from "./errors.js";
|
|
30
35
|
import { abortable, abortReason } from "./abort.js";
|
|
31
36
|
import { Identity, Crypt } from "./crypto/index.js";
|
|
32
37
|
import { RESOLVE_BUDGET_MS, Resolver } from "./discovery/resolver.js";
|
|
@@ -35,7 +40,7 @@ import { PacketType } from "./transport/packet.js";
|
|
|
35
40
|
import type { Packet } from "./transport/packet.js";
|
|
36
41
|
import { CommandTracker, appendReturnCode } from "./commands.js";
|
|
37
42
|
import { encodeRequest, parseServerError } from "@honeybbq/teamspeak-core/internal";
|
|
38
|
-
import { uploadFileData, downloadFileData } from "./transfer.js";
|
|
43
|
+
import { uploadFileData, downloadFileData, type FileTransferOptions } from "./transfer.js";
|
|
39
44
|
import { buildCommandChain, buildEventChain } from "./events.js";
|
|
40
45
|
import { processInit1 } from "./handshake/crypt-handshake.js";
|
|
41
46
|
import {
|
|
@@ -103,7 +108,12 @@ export interface ClientInternals {
|
|
|
103
108
|
clid: number;
|
|
104
109
|
capabilities: ServerCapabilities;
|
|
105
110
|
/** Mark the attempt in progress connected. */
|
|
106
|
-
markConnected(): void;
|
|
111
|
+
markConnected(initServer?: Readonly<Record<string, string>>): void;
|
|
112
|
+
/**
|
|
113
|
+
* The shared secret exists: the attempt moves from its handshake to joining.
|
|
114
|
+
* `serverPublicKey` is the server's `omega`, as it sent it.
|
|
115
|
+
*/
|
|
116
|
+
markHandshakeDone(serverPublicKey?: string): void;
|
|
107
117
|
/**
|
|
108
118
|
* Send a command line without waiting for an answer, on the handshake's
|
|
109
119
|
* own path: it needs an open socket, not a connected session.
|
|
@@ -145,7 +155,47 @@ const DISCONNECT_TIMEOUT_MS = 1_000;
|
|
|
145
155
|
/** How long each reconnection attempt may take. */
|
|
146
156
|
const RECONNECT_ATTEMPT_TIMEOUT_MS = 30_000;
|
|
147
157
|
|
|
148
|
-
/**
|
|
158
|
+
/** What the trace hooks need to know about one attempt, from its first milestone on. */
|
|
159
|
+
interface AttemptTrace {
|
|
160
|
+
readonly attempt: number;
|
|
161
|
+
readonly reconnect: boolean;
|
|
162
|
+
/** When the address was about to be resolved. */
|
|
163
|
+
readonly startedAt: number;
|
|
164
|
+
/** When the socket was about to be opened. */
|
|
165
|
+
connectStartedAt: number;
|
|
166
|
+
phase: ConnectPhase;
|
|
167
|
+
/** The `host:port` being dialled; empty until the address is resolved. */
|
|
168
|
+
target: string;
|
|
169
|
+
sessionStarted: boolean;
|
|
170
|
+
welcomeDone: boolean;
|
|
171
|
+
/** The channel rows the welcome sequence has delivered so far. */
|
|
172
|
+
channels: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A session that began with `sessionStart`, until its `sessionEnd`. */
|
|
176
|
+
interface TracedSession {
|
|
177
|
+
readonly clientID: ClientID;
|
|
178
|
+
readonly startedAt: number;
|
|
179
|
+
readonly attempt: number;
|
|
180
|
+
readonly reconnect: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The `initserver` parameters a trace never sees, because they could carry a secret. */
|
|
184
|
+
const SECRET_INITSERVER_PARAMS: ReadonlySet<string> = new Set([
|
|
185
|
+
"client_server_password",
|
|
186
|
+
"client_default_channel_password",
|
|
187
|
+
"client_default_token",
|
|
188
|
+
"client_key_offset",
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One connection attempt, settled once by the handshake or its failure.
|
|
193
|
+
*
|
|
194
|
+
* The settling is also what makes the trace's promise true: `sessionStart`
|
|
195
|
+
* is fired by whoever settles it as connected and `attemptFailed` by `reject`
|
|
196
|
+
* and `rejectAfter`, all behind the one `settled` flag, so an attempt gets
|
|
197
|
+
* exactly one of them.
|
|
198
|
+
*/
|
|
149
199
|
interface ConnectAttempt {
|
|
150
200
|
readonly promise: Promise<void>;
|
|
151
201
|
/**
|
|
@@ -155,6 +205,10 @@ interface ConnectAttempt {
|
|
|
155
205
|
*/
|
|
156
206
|
readonly signal: AbortSignal;
|
|
157
207
|
settled: boolean;
|
|
208
|
+
/** The trace state, once the attempt has started. */
|
|
209
|
+
trace: AttemptTrace | undefined;
|
|
210
|
+
/** Called once, if the attempt settles as failed. */
|
|
211
|
+
onFail: ((error: unknown) => void) | undefined;
|
|
158
212
|
resolve(): void;
|
|
159
213
|
reject(error: unknown): void;
|
|
160
214
|
/**
|
|
@@ -181,6 +235,8 @@ function newConnectAttempt(): ConnectAttempt {
|
|
|
181
235
|
promise,
|
|
182
236
|
signal: controller.signal,
|
|
183
237
|
settled: false,
|
|
238
|
+
trace: undefined,
|
|
239
|
+
onFail: undefined,
|
|
184
240
|
resolve() {
|
|
185
241
|
if (attempt.settled) return;
|
|
186
242
|
attempt.settled = true;
|
|
@@ -189,12 +245,14 @@ function newConnectAttempt(): ConnectAttempt {
|
|
|
189
245
|
reject(error) {
|
|
190
246
|
if (attempt.settled) return;
|
|
191
247
|
attempt.settled = true;
|
|
248
|
+
attempt.onFail?.(error);
|
|
192
249
|
controller.abort(error);
|
|
193
250
|
reject(error);
|
|
194
251
|
},
|
|
195
252
|
rejectAfter(error, until) {
|
|
196
253
|
if (attempt.settled) return;
|
|
197
254
|
attempt.settled = true;
|
|
255
|
+
attempt.onFail?.(error);
|
|
198
256
|
controller.abort(error);
|
|
199
257
|
void until.finally(() => reject(error));
|
|
200
258
|
},
|
|
@@ -250,6 +308,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
250
308
|
#clientInitOptions: ClientInitOptions;
|
|
251
309
|
#socketFactory: SocketFactory | undefined;
|
|
252
310
|
#fileTransferSocketFactory: FileTransferSocketFactory | undefined;
|
|
311
|
+
/** A copy of the caller's trace, so changing theirs later changes nothing. */
|
|
312
|
+
#trace: ClientTrace | undefined;
|
|
313
|
+
/** The session in progress, for its `sessionEnd`; cleared when that has been reported. */
|
|
314
|
+
#session: TracedSession | undefined;
|
|
253
315
|
/** Undefined when the client does not reconnect. */
|
|
254
316
|
#reconnectOptions: ReconnectOptions | undefined;
|
|
255
317
|
/**
|
|
@@ -270,6 +332,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
270
332
|
* the new one instead.
|
|
271
333
|
*/
|
|
272
334
|
#leaving: Promise<void> | undefined;
|
|
335
|
+
/** Ends a disconnect()'s wait for its goodbye to be answered, while it waits. */
|
|
336
|
+
#goodbyeAnswered: (() => void) | undefined;
|
|
273
337
|
/** How many times disconnect() was called, so a waiting connect() sees one. */
|
|
274
338
|
#disconnects = 0;
|
|
275
339
|
/**
|
|
@@ -309,6 +373,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
309
373
|
this.#socketFactory = options.socketFactory;
|
|
310
374
|
this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
|
|
311
375
|
this.#reconnectOptions = options.reconnect;
|
|
376
|
+
this.#trace = options.trace === undefined ? undefined : { ...options.trace };
|
|
312
377
|
|
|
313
378
|
this.#crypt = new Crypt(identity);
|
|
314
379
|
this.#handler = this.#newHandler();
|
|
@@ -346,7 +411,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
346
411
|
set capabilities(value) {
|
|
347
412
|
self.#capabilities = value;
|
|
348
413
|
},
|
|
349
|
-
markConnected: () => this.#markConnected(),
|
|
414
|
+
markConnected: (initServer) => this.#markConnected(initServer),
|
|
415
|
+
markHandshakeDone: (serverPublicKey) => this.#markHandshakeDone(serverPublicKey),
|
|
350
416
|
execNoWait: (command) => this.#execNoWait(command, undefined, true),
|
|
351
417
|
handlePacket: (packet) => this.#handlePacket(packet),
|
|
352
418
|
};
|
|
@@ -396,6 +462,14 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
396
462
|
* after `await client.connect()` still hears it.
|
|
397
463
|
*/
|
|
398
464
|
async connect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
|
|
465
|
+
return this.#connect(options, 1, false);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async #connect(
|
|
469
|
+
options: { readonly signal?: AbortSignal | undefined },
|
|
470
|
+
attemptNumber: number,
|
|
471
|
+
reconnect: boolean,
|
|
472
|
+
): Promise<void> {
|
|
399
473
|
const { signal } = options;
|
|
400
474
|
signal?.throwIfAborted();
|
|
401
475
|
const disconnects = this.#disconnects;
|
|
@@ -413,6 +487,27 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
413
487
|
this.#resetForConnect();
|
|
414
488
|
this.#status = ClientStatus.Connecting;
|
|
415
489
|
const attempt = this.#attempt;
|
|
490
|
+
const trace: AttemptTrace = {
|
|
491
|
+
attempt: attemptNumber,
|
|
492
|
+
reconnect,
|
|
493
|
+
startedAt: performance.now(),
|
|
494
|
+
connectStartedAt: 0,
|
|
495
|
+
phase: ConnectPhase.Open,
|
|
496
|
+
target: "",
|
|
497
|
+
sessionStarted: false,
|
|
498
|
+
welcomeDone: false,
|
|
499
|
+
channels: 0,
|
|
500
|
+
};
|
|
501
|
+
attempt.trace = trace;
|
|
502
|
+
attempt.onFail = (error) =>
|
|
503
|
+
this.#callHook(this.#trace?.attemptFailed, () => ({
|
|
504
|
+
attempt: trace.attempt,
|
|
505
|
+
reconnect: trace.reconnect,
|
|
506
|
+
phase: trace.phase,
|
|
507
|
+
error,
|
|
508
|
+
target: trace.target,
|
|
509
|
+
durationMs: performance.now() - trace.startedAt,
|
|
510
|
+
}));
|
|
416
511
|
|
|
417
512
|
// The handshake may complete just as the signal fires, in which case
|
|
418
513
|
// #failConnection leaves the session alone and the caller gets the
|
|
@@ -421,10 +516,41 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
421
516
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
422
517
|
try {
|
|
423
518
|
try {
|
|
424
|
-
|
|
519
|
+
this.#callHook(this.#trace?.resolveStart, () => ({
|
|
520
|
+
attempt: trace.attempt,
|
|
521
|
+
reconnect: trace.reconnect,
|
|
522
|
+
addr: this.#addr,
|
|
523
|
+
}));
|
|
524
|
+
const resolveStarted = performance.now();
|
|
525
|
+
const resolution = await this.#resolveAddr(attempt.signal);
|
|
526
|
+
const targetAddr = resolution.target;
|
|
527
|
+
trace.target = targetAddr;
|
|
528
|
+
if (!attempt.settled) {
|
|
529
|
+
this.#callHook(this.#trace?.resolveDone, () => ({
|
|
530
|
+
attempt: trace.attempt,
|
|
531
|
+
reconnect: trace.reconnect,
|
|
532
|
+
addr: this.#addr,
|
|
533
|
+
target: targetAddr,
|
|
534
|
+
source: resolution.source,
|
|
535
|
+
candidates: resolution.candidates,
|
|
536
|
+
error: resolution.error,
|
|
537
|
+
durationMs: performance.now() - resolveStarted,
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
425
540
|
if (!attempt.settled) {
|
|
426
541
|
this.#logger.info("connecting to server", { address: targetAddr });
|
|
542
|
+
trace.connectStartedAt = performance.now();
|
|
543
|
+
this.#callHook(this.#trace?.connectStart, () => ({
|
|
544
|
+
attempt: trace.attempt,
|
|
545
|
+
reconnect: trace.reconnect,
|
|
546
|
+
target: targetAddr,
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
if (!attempt.settled) {
|
|
427
550
|
await this.#handler.connect(targetAddr, attempt.signal);
|
|
551
|
+
// The socket is open and the first packet is out: a failure from
|
|
552
|
+
// here to the shared secret is the handshake's.
|
|
553
|
+
if (trace.phase === ConnectPhase.Open) trace.phase = ConnectPhase.Handshake;
|
|
428
554
|
}
|
|
429
555
|
} catch (error) {
|
|
430
556
|
// Without this the client stays Connecting and every later connect()
|
|
@@ -479,20 +605,35 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
479
605
|
this.#logger.info("disconnecting from server");
|
|
480
606
|
|
|
481
607
|
if (wasConnected) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
608
|
+
this.#callHook(this.#trace?.disconnectStart, () => ({}));
|
|
609
|
+
const goodbyeStarted = performance.now();
|
|
610
|
+
// The server answers clientdisconnect with a notifyclientleftview about
|
|
611
|
+
// this client, and no error line; either one ends the wait. A goodbye
|
|
612
|
+
// that is not answered in time is a courtesy lost: the connection
|
|
613
|
+
// closes regardless.
|
|
614
|
+
const left = new Promise<boolean>((resolve) => {
|
|
615
|
+
this.#goodbyeAnswered = () => resolve(true);
|
|
616
|
+
});
|
|
617
|
+
// On the internal path: the status already says Disconnected.
|
|
618
|
+
const answered = this.#exec(
|
|
619
|
+
"clientdisconnect reasonmsg=Shutdown",
|
|
620
|
+
{ signal, timeout: DISCONNECT_TIMEOUT_MS },
|
|
621
|
+
true,
|
|
622
|
+
).then(
|
|
623
|
+
() => true,
|
|
624
|
+
() => false,
|
|
625
|
+
);
|
|
626
|
+
const acknowledged = await Promise.race([answered, left]);
|
|
627
|
+
this.#goodbyeAnswered = undefined;
|
|
628
|
+
this.#callHook(this.#trace?.disconnectDone, () => ({
|
|
629
|
+
acknowledged,
|
|
630
|
+
durationMs: performance.now() - goodbyeStarted,
|
|
631
|
+
}));
|
|
492
632
|
}
|
|
493
633
|
|
|
494
634
|
this.#handler.close();
|
|
495
635
|
this.#cmdTrack.reset(new NotConnectedError());
|
|
636
|
+
this.#endSession(undefined);
|
|
496
637
|
// Announced here; #handleConnectionClosed stays quiet once the status is
|
|
497
638
|
// already Disconnected, so listeners hear about it once.
|
|
498
639
|
await this.#announceDisconnected(undefined);
|
|
@@ -684,11 +825,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
684
825
|
* ```
|
|
685
826
|
*
|
|
686
827
|
* Events that arrive while the loop body runs are buffered, so none is
|
|
687
|
-
* missed
|
|
688
|
-
*
|
|
689
|
-
* `signal`
|
|
690
|
-
*
|
|
691
|
-
*
|
|
828
|
+
* missed. The buffer is unbounded: an iterator that is not consumed as fast
|
|
829
|
+
* as events arrive holds every one of them in memory until it is read, so
|
|
830
|
+
* stop iterating (or abort `signal`) when you stop reading. Voice is the
|
|
831
|
+
* exception: at most 256 `voiceData` frames are buffered, and the rest are
|
|
832
|
+
* dropped, as for a listener that falls behind.
|
|
833
|
+
*
|
|
834
|
+
* Iteration ends when the loop exits early (`break`, `return`, a throw), and
|
|
835
|
+
* — for every event but `disconnected` — when the client disconnects. When
|
|
836
|
+
* `signal` aborts, listening stops at once, whatever is buffered is
|
|
837
|
+
* discarded, and the pending and every later `next()` reject with the
|
|
838
|
+
* signal's reason, as `events.on` in Node does.
|
|
839
|
+
*
|
|
840
|
+
* `next()` may be called again before an earlier call has settled; the calls
|
|
841
|
+
* are answered in order.
|
|
692
842
|
*/
|
|
693
843
|
events<K extends keyof EventMap>(
|
|
694
844
|
event: K,
|
|
@@ -696,55 +846,72 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
696
846
|
): AsyncIterableIterator<EventMap[K]> {
|
|
697
847
|
const { signal } = options;
|
|
698
848
|
const buffered: Array<EventMap[K]> = [];
|
|
699
|
-
|
|
849
|
+
// The next() calls waiting for an event, oldest first.
|
|
850
|
+
const waiting: Array<{
|
|
851
|
+
resolve: (result: IteratorResult<EventMap[K]>) => void;
|
|
852
|
+
reject: (reason: unknown) => void;
|
|
853
|
+
}> = [];
|
|
700
854
|
let ended = false;
|
|
855
|
+
let aborted = false;
|
|
856
|
+
|
|
857
|
+
const finishWaiting = (): void => {
|
|
858
|
+
for (const waiter of waiting.splice(0)) {
|
|
859
|
+
waiter.resolve({ value: undefined, done: true });
|
|
860
|
+
}
|
|
861
|
+
};
|
|
701
862
|
|
|
702
863
|
const stopListening = this.on(event, ((payload: EventMap[K]) => {
|
|
703
864
|
if (ended) return;
|
|
865
|
+
const waiter = waiting.shift();
|
|
866
|
+
if (waiter) {
|
|
867
|
+
waiter.resolve({ value: payload, done: false });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
704
870
|
if (event === "voiceData" && buffered.length >= MAX_PENDING_DELIVERIES) {
|
|
705
871
|
this.#dropVoice();
|
|
706
872
|
return;
|
|
707
873
|
}
|
|
708
874
|
buffered.push(payload);
|
|
709
|
-
wake?.();
|
|
710
875
|
}) as Listener<K>);
|
|
711
876
|
// Ends after what is already buffered, which arrived before the disconnect.
|
|
712
877
|
const stopOnDisconnect =
|
|
713
878
|
event === "disconnected"
|
|
714
879
|
? undefined
|
|
715
880
|
: this.on("disconnected", () => {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
wake?.();
|
|
881
|
+
cleanup();
|
|
882
|
+
finishWaiting();
|
|
719
883
|
});
|
|
720
884
|
const cleanup = (): void => {
|
|
721
885
|
ended = true;
|
|
722
886
|
stopListening();
|
|
723
887
|
stopOnDisconnect?.();
|
|
724
888
|
signal?.removeEventListener("abort", abort);
|
|
725
|
-
wake?.();
|
|
726
889
|
};
|
|
727
890
|
const abort = (): void => {
|
|
891
|
+
aborted = true;
|
|
728
892
|
buffered.length = 0;
|
|
729
893
|
cleanup();
|
|
894
|
+
const reason = abortReason(signal!);
|
|
895
|
+
for (const waiter of waiting.splice(0)) waiter.reject(reason);
|
|
730
896
|
};
|
|
731
897
|
if (signal?.aborted) abort();
|
|
732
898
|
else signal?.addEventListener("abort", abort, { once: true });
|
|
733
899
|
|
|
734
900
|
const iterator: AsyncIterableIterator<EventMap[K]> = {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
901
|
+
next(): Promise<IteratorResult<EventMap[K]>> {
|
|
902
|
+
if (aborted) return Promise.reject(abortReason(signal!));
|
|
903
|
+
if (buffered.length > 0) return Promise.resolve({ value: buffered.shift()!, done: false });
|
|
904
|
+
if (ended) {
|
|
905
|
+
cleanup();
|
|
906
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
739
907
|
}
|
|
740
|
-
|
|
741
|
-
cleanup();
|
|
742
|
-
return { value: undefined, done: true };
|
|
908
|
+
return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
|
|
743
909
|
},
|
|
744
|
-
|
|
910
|
+
return(): Promise<IteratorResult<EventMap[K]>> {
|
|
745
911
|
buffered.length = 0;
|
|
746
912
|
cleanup();
|
|
747
|
-
|
|
913
|
+
finishWaiting();
|
|
914
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
748
915
|
},
|
|
749
916
|
[Symbol.asyncIterator]() {
|
|
750
917
|
return iterator;
|
|
@@ -814,7 +981,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
814
981
|
host: string,
|
|
815
982
|
info: FileUploadInfo,
|
|
816
983
|
data: Readable,
|
|
817
|
-
options:
|
|
984
|
+
options: Pick<FileTransferOptions, "signal" | "trace"> = {},
|
|
818
985
|
): Promise<void> {
|
|
819
986
|
return uploadFileData(host, info, data, {
|
|
820
987
|
...options,
|
|
@@ -830,7 +997,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
830
997
|
host: string,
|
|
831
998
|
info: FileDownloadInfo,
|
|
832
999
|
dest: Writable,
|
|
833
|
-
options:
|
|
1000
|
+
options: Pick<FileTransferOptions, "signal" | "trace"> = {},
|
|
834
1001
|
): Promise<void> {
|
|
835
1002
|
return downloadFileData(host, info, dest, {
|
|
836
1003
|
...options,
|
|
@@ -840,15 +1007,125 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
840
1007
|
|
|
841
1008
|
// ---- Internal (package-visible) ------------------------------------------
|
|
842
1009
|
|
|
843
|
-
#markConnected(): void {
|
|
1010
|
+
#markConnected(initServer: Readonly<Record<string, string>> = {}): void {
|
|
844
1011
|
// Only the attempt in progress can complete: never one that disconnect()
|
|
845
1012
|
// or a failure has already ended.
|
|
846
1013
|
if (this.#status !== ClientStatus.Connecting || this.#attempt.settled) return;
|
|
847
1014
|
this.#status = ClientStatus.Connected;
|
|
1015
|
+
// Settled before the hook runs, so that nothing it does can turn this
|
|
1016
|
+
// attempt into a failed one; connect() resumes only after the hook returns.
|
|
848
1017
|
this.#attempt.resolve();
|
|
1018
|
+
const trace = this.#attempt.trace;
|
|
1019
|
+
if (trace !== undefined) {
|
|
1020
|
+
trace.sessionStarted = true;
|
|
1021
|
+
this.#session = {
|
|
1022
|
+
clientID: asClientID(this.#clid),
|
|
1023
|
+
startedAt: performance.now(),
|
|
1024
|
+
attempt: trace.attempt,
|
|
1025
|
+
reconnect: trace.reconnect,
|
|
1026
|
+
};
|
|
1027
|
+
this.#callHook(this.#trace?.sessionStart, () => {
|
|
1028
|
+
const safe: Record<string, string> = {};
|
|
1029
|
+
for (const [key, value] of Object.entries(initServer)) {
|
|
1030
|
+
if (!SECRET_INITSERVER_PARAMS.has(key)) safe[key] = value;
|
|
1031
|
+
}
|
|
1032
|
+
const talkPower = Number.parseInt(initServer["client_talk_power"] ?? "", 10);
|
|
1033
|
+
return {
|
|
1034
|
+
attempt: trace.attempt,
|
|
1035
|
+
reconnect: trace.reconnect,
|
|
1036
|
+
clientID: asClientID(this.#clid),
|
|
1037
|
+
nickname: initServer["acn"] ?? this.#nickname,
|
|
1038
|
+
talkPower: Number.isNaN(talkPower) ? 0 : talkPower,
|
|
1039
|
+
initServer: Object.freeze(safe),
|
|
1040
|
+
capabilities: this.#capabilities,
|
|
1041
|
+
durationMs: performance.now() - trace.connectStartedAt,
|
|
1042
|
+
};
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
849
1045
|
this.#emit("connected", undefined);
|
|
850
1046
|
}
|
|
851
1047
|
|
|
1048
|
+
/** The socket is open: the attempt's handshake begins. */
|
|
1049
|
+
#markConnectDone(socket: DatagramSocket): void {
|
|
1050
|
+
const trace = this.#attempt.trace;
|
|
1051
|
+
if (trace === undefined || this.#attempt.settled || trace.phase !== ConnectPhase.Open) return;
|
|
1052
|
+
trace.phase = ConnectPhase.Handshake;
|
|
1053
|
+
this.#callHook(this.#trace?.connectDone, () => {
|
|
1054
|
+
let localAddr = "";
|
|
1055
|
+
try {
|
|
1056
|
+
const local = socket.address?.();
|
|
1057
|
+
if (local !== undefined) localAddr = joinHostPort(local.address, String(local.port));
|
|
1058
|
+
} catch {
|
|
1059
|
+
// A socket that cannot say where it is bound has no local address to report.
|
|
1060
|
+
}
|
|
1061
|
+
return {
|
|
1062
|
+
attempt: trace.attempt,
|
|
1063
|
+
reconnect: trace.reconnect,
|
|
1064
|
+
target: trace.target,
|
|
1065
|
+
localAddr,
|
|
1066
|
+
durationMs: performance.now() - trace.connectStartedAt,
|
|
1067
|
+
};
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#markHandshakeDone(serverPublicKey = ""): void {
|
|
1072
|
+
const trace = this.#attempt.trace;
|
|
1073
|
+
if (trace === undefined || this.#attempt.settled || trace.phase !== ConnectPhase.Handshake) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
trace.phase = ConnectPhase.Join;
|
|
1077
|
+
this.#callHook(this.#trace?.handshakeDone, () => {
|
|
1078
|
+
const puzzle = puzzleOf(this.#crypt);
|
|
1079
|
+
return {
|
|
1080
|
+
attempt: trace.attempt,
|
|
1081
|
+
reconnect: trace.reconnect,
|
|
1082
|
+
durationMs: performance.now() - trace.connectStartedAt,
|
|
1083
|
+
puzzleLevel: puzzle.level,
|
|
1084
|
+
puzzleDurationMs: puzzle.durationMs,
|
|
1085
|
+
// The handler is this attempt's own, so its count starts at the socket.
|
|
1086
|
+
resends: this.#handler.resends,
|
|
1087
|
+
serverPublicKey,
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
#markWelcomeDone(): void {
|
|
1093
|
+
const trace = this.#attempt.trace;
|
|
1094
|
+
if (trace === undefined || !trace.sessionStarted || trace.welcomeDone) return;
|
|
1095
|
+
if (this.#status !== ClientStatus.Connected) return;
|
|
1096
|
+
trace.welcomeDone = true;
|
|
1097
|
+
this.#callHook(this.#trace?.welcomeDone, () => ({
|
|
1098
|
+
attempt: trace.attempt,
|
|
1099
|
+
reconnect: trace.reconnect,
|
|
1100
|
+
channelID: this.channelID,
|
|
1101
|
+
channels: trace.channels,
|
|
1102
|
+
clients: this.#clients.size,
|
|
1103
|
+
durationMs: performance.now() - trace.connectStartedAt,
|
|
1104
|
+
}));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Report the end of the session in progress, once. */
|
|
1108
|
+
#endSession(error: unknown): void {
|
|
1109
|
+
const session = this.#session;
|
|
1110
|
+
if (session === undefined) return;
|
|
1111
|
+
this.#session = undefined;
|
|
1112
|
+
this.#callHook(this.#trace?.sessionEnd, () => ({
|
|
1113
|
+
clientID: session.clientID,
|
|
1114
|
+
error,
|
|
1115
|
+
durationMs: performance.now() - session.startedAt,
|
|
1116
|
+
attempt: session.attempt,
|
|
1117
|
+
reconnect: session.reconnect,
|
|
1118
|
+
}));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Call a trace hook, if there is one, and never let it touch the
|
|
1123
|
+
* connection: see callHook. The info is built only when there is a hook.
|
|
1124
|
+
*/
|
|
1125
|
+
#callHook<I>(hook: ((info: I) => unknown) | undefined, info: () => I): void {
|
|
1126
|
+
callHook(hook, this.#trace, info);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
852
1129
|
// ---- Private -------------------------------------------------------------
|
|
853
1130
|
|
|
854
1131
|
#newHandler(): PacketHandler {
|
|
@@ -862,6 +1139,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
862
1139
|
handler.onClosed = (err) => {
|
|
863
1140
|
if (handler === this.#handler) this.#handleConnectionClosed(err);
|
|
864
1141
|
};
|
|
1142
|
+
handler.onOpen = (socket) => {
|
|
1143
|
+
if (handler === this.#handler) this.#markConnectDone(socket);
|
|
1144
|
+
};
|
|
865
1145
|
handler.onReceiveError = (error) => {
|
|
866
1146
|
if (handler === this.#handler) this.#handleReceiveError(error);
|
|
867
1147
|
};
|
|
@@ -879,7 +1159,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
879
1159
|
this.#capabilities = unknownServerCapabilities();
|
|
880
1160
|
}
|
|
881
1161
|
|
|
882
|
-
async #resolveAddr(signal: AbortSignal): Promise<
|
|
1162
|
+
async #resolveAddr(signal: AbortSignal): Promise<{
|
|
1163
|
+
target: string;
|
|
1164
|
+
source: string;
|
|
1165
|
+
candidates: readonly ResolvedAddr[];
|
|
1166
|
+
error: unknown;
|
|
1167
|
+
}> {
|
|
883
1168
|
// Parsed, not tested for a colon: an IPv6 literal has colons and no port.
|
|
884
1169
|
const { host, port } = splitHostPort(this.#addr);
|
|
885
1170
|
const fallback = joinHostPort(host, port);
|
|
@@ -899,13 +1184,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
899
1184
|
this.#resolver.resolve(this.#addr, { signal: lookup }),
|
|
900
1185
|
lookup,
|
|
901
1186
|
);
|
|
902
|
-
|
|
1187
|
+
const first = resolved[0];
|
|
1188
|
+
return {
|
|
1189
|
+
target: first?.addr ?? fallback,
|
|
1190
|
+
source: first?.source ?? "",
|
|
1191
|
+
candidates: resolved,
|
|
1192
|
+
error: undefined,
|
|
1193
|
+
};
|
|
903
1194
|
} catch (error) {
|
|
904
1195
|
signal.throwIfAborted();
|
|
1196
|
+
const reason = budget.signal.aborted ? budget.signal.reason : error;
|
|
905
1197
|
this.#logger.warn("address resolution failed, falling back to direct", {
|
|
906
|
-
error: String(
|
|
1198
|
+
error: String(reason),
|
|
907
1199
|
});
|
|
908
|
-
return fallback;
|
|
1200
|
+
return { target: fallback, source: "", candidates: [], error: reason };
|
|
909
1201
|
} finally {
|
|
910
1202
|
clearTimeout(timer);
|
|
911
1203
|
}
|
|
@@ -913,15 +1205,15 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
913
1205
|
|
|
914
1206
|
/**
|
|
915
1207
|
* A datagram from the server could not be handled. During the handshake that
|
|
916
|
-
* is the connection failing, reported as a
|
|
1208
|
+
* is the connection failing, reported as a HandshakeError with the cause;
|
|
917
1209
|
* once connected, one bad packet is not worth the session, so it is dropped.
|
|
918
1210
|
*/
|
|
919
1211
|
#handleReceiveError(error: unknown): void {
|
|
920
1212
|
if (this.#status !== ClientStatus.Connected) {
|
|
921
1213
|
this.#failConnection(
|
|
922
|
-
error instanceof
|
|
1214
|
+
error instanceof HandshakeError
|
|
923
1215
|
? error
|
|
924
|
-
: new
|
|
1216
|
+
: new HandshakeError("the handshake failed on a malformed packet", { cause: error }),
|
|
925
1217
|
);
|
|
926
1218
|
return;
|
|
927
1219
|
}
|
|
@@ -1017,9 +1309,25 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1017
1309
|
// which is how a channellist could come back with duplicate and empty rows.
|
|
1018
1310
|
if (cmd.name === "channellistfinished") {
|
|
1019
1311
|
this.#cmdTrack.signalWelcomeComplete();
|
|
1312
|
+
this.#markWelcomeDone();
|
|
1020
1313
|
return;
|
|
1021
1314
|
}
|
|
1022
1315
|
|
|
1316
|
+
if (cmd.name === "channellist") {
|
|
1317
|
+
const trace = this.#attempt.trace;
|
|
1318
|
+
if (trace !== undefined && !trace.welcomeDone) trace.channels++;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// The server answers our goodbye by announcing that we left, not with an
|
|
1322
|
+
// error line; that announcement is what disconnect() waits for.
|
|
1323
|
+
if (
|
|
1324
|
+
cmd.name === "notifyclientleftview" &&
|
|
1325
|
+
this.#goodbyeAnswered !== undefined &&
|
|
1326
|
+
cmd.params["clid"] === String(this.#clid)
|
|
1327
|
+
) {
|
|
1328
|
+
this.#goodbyeAnswered();
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1023
1331
|
if (cmd.name.startsWith("notify")) {
|
|
1024
1332
|
// Some commands are answered by a notification instead of data rows;
|
|
1025
1333
|
// it still goes on to be dispatched as an event, since the same
|
|
@@ -1174,7 +1482,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1174
1482
|
*/
|
|
1175
1483
|
#dropVoice(): void {
|
|
1176
1484
|
this.#voiceDropped++;
|
|
1177
|
-
const now =
|
|
1485
|
+
const now = performance.now();
|
|
1178
1486
|
if (now - this.#voiceDropWarnedAt < VOICE_DROP_WARN_INTERVAL_MS) return;
|
|
1179
1487
|
this.#voiceDropWarnedAt = now;
|
|
1180
1488
|
// The count since the last warning, so each one says how bad it is now.
|
|
@@ -1272,6 +1580,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1272
1580
|
// Never undefined for a drop: undefined means disconnect() only.
|
|
1273
1581
|
const cause = err ?? new TeamspeakError("connection closed");
|
|
1274
1582
|
this.#lastDrop = cause;
|
|
1583
|
+
this.#endSession(cause);
|
|
1275
1584
|
|
|
1276
1585
|
// Announced first, then retried: a caller watching `disconnected` should
|
|
1277
1586
|
// hear about the drop even when a reconnect is already on its way, because
|
|
@@ -1302,27 +1611,43 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1302
1611
|
|
|
1303
1612
|
const stop = new AbortController();
|
|
1304
1613
|
this.#stopReconnecting = stop;
|
|
1614
|
+
// The attempts the loop has made, and why it stopped, for `reconnectStopped`.
|
|
1615
|
+
let made = 0;
|
|
1616
|
+
let stoppedBy: { readonly cause: unknown } | undefined;
|
|
1305
1617
|
try {
|
|
1306
1618
|
for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
|
|
1307
|
-
if (this.#stopped)
|
|
1619
|
+
if (this.#stopped) {
|
|
1620
|
+
stoppedBy = { cause: undefined };
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1308
1623
|
let retry: boolean;
|
|
1309
1624
|
try {
|
|
1310
1625
|
retry = shouldRetry(err, attempt);
|
|
1311
1626
|
} catch (error) {
|
|
1312
1627
|
this.#logger.error("shouldRetry threw; not reconnecting", { error: String(error) });
|
|
1628
|
+
stoppedBy = { cause: err };
|
|
1313
1629
|
return;
|
|
1314
1630
|
}
|
|
1315
1631
|
if (!retry) {
|
|
1316
1632
|
this.#logger.info("not retrying", { attempt, reason: err.message });
|
|
1633
|
+
stoppedBy = { cause: err };
|
|
1317
1634
|
return;
|
|
1318
1635
|
}
|
|
1319
1636
|
|
|
1320
1637
|
const delay = backoffDelay(policy, attempt);
|
|
1321
1638
|
this.#logger.info("reconnecting", { attempt, delayMs: delay });
|
|
1639
|
+
this.#callHook(this.#trace?.reconnectScheduled, () => ({
|
|
1640
|
+
attempt,
|
|
1641
|
+
delayMs: delay,
|
|
1642
|
+
cause: err,
|
|
1643
|
+
}));
|
|
1322
1644
|
// A timer that keeps the process alive: an application that does
|
|
1323
1645
|
// nothing but stay connected would otherwise exit during the wait.
|
|
1324
1646
|
await sleep(delay, stop.signal);
|
|
1325
|
-
if (this.#stopped)
|
|
1647
|
+
if (this.#stopped) {
|
|
1648
|
+
stoppedBy = { cause: undefined };
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1326
1651
|
// Connected, or connecting, by hand during the wait: nothing is left to
|
|
1327
1652
|
// retry, and an attempt now would only fail with AlreadyConnectedError.
|
|
1328
1653
|
if (this.#status !== ClientStatus.Disconnected) {
|
|
@@ -1331,7 +1656,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1331
1656
|
}
|
|
1332
1657
|
|
|
1333
1658
|
try {
|
|
1334
|
-
|
|
1659
|
+
made++;
|
|
1660
|
+
await this.#connect(
|
|
1661
|
+
{ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) },
|
|
1662
|
+
attempt,
|
|
1663
|
+
true,
|
|
1664
|
+
);
|
|
1335
1665
|
// Read afresh: the status may have changed while connect() resolved.
|
|
1336
1666
|
if (this.status === ClientStatus.Connected) {
|
|
1337
1667
|
this.#logger.info("reconnected", { attempt });
|
|
@@ -1343,11 +1673,19 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1343
1673
|
err = this.#lastDrop ?? new TeamspeakError("connection closed");
|
|
1344
1674
|
} catch (retryErr) {
|
|
1345
1675
|
if (retryErr instanceof AlreadyConnectedError) return;
|
|
1346
|
-
err =
|
|
1676
|
+
err =
|
|
1677
|
+
retryErr instanceof Error
|
|
1678
|
+
? retryErr
|
|
1679
|
+
: new TeamspeakError(String(retryErr), { cause: retryErr });
|
|
1347
1680
|
}
|
|
1348
1681
|
}
|
|
1349
1682
|
this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
|
|
1683
|
+
stoppedBy = { cause: err };
|
|
1350
1684
|
} finally {
|
|
1685
|
+
if (stoppedBy !== undefined) {
|
|
1686
|
+
const { cause } = stoppedBy;
|
|
1687
|
+
this.#callHook(this.#trace?.reconnectStopped, () => ({ attempts: made, cause }));
|
|
1688
|
+
}
|
|
1351
1689
|
this.#reconnecting = false;
|
|
1352
1690
|
if (this.#stopReconnecting === stop) this.#stopReconnecting = undefined;
|
|
1353
1691
|
}
|
|
@@ -1371,7 +1709,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1371
1709
|
this.#status = ClientStatus.Disconnected;
|
|
1372
1710
|
this.#handler.close();
|
|
1373
1711
|
void this.#announceDisconnected(
|
|
1374
|
-
error instanceof Error ? error : new TeamspeakError(String(error)),
|
|
1712
|
+
error instanceof Error ? error : new TeamspeakError(String(error), { cause: error }),
|
|
1375
1713
|
);
|
|
1376
1714
|
}
|
|
1377
1715
|
}
|