@honeybbq/teamspeak 1.0.0-beta.19 → 1.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/api/avatars.d.ts.map +1 -1
- package/dist/client/api/avatars.js +11 -2
- package/dist/client/api/avatars.js.map +1 -1
- package/dist/client/api/files.d.ts +36 -14
- package/dist/client/api/files.d.ts.map +1 -1
- package/dist/client/api/files.js +58 -9
- package/dist/client/api/files.js.map +1 -1
- package/dist/client/api/upload-checks.d.ts +36 -0
- package/dist/client/api/upload-checks.d.ts.map +1 -0
- package/dist/client/api/upload-checks.js +77 -0
- package/dist/client/api/upload-checks.js.map +1 -0
- package/dist/client/api/voice.d.ts +2 -1
- package/dist/client/api/voice.d.ts.map +1 -1
- package/dist/client/api/voice.js +6 -2
- package/dist/client/api/voice.js.map +1 -1
- package/dist/client/client.d.ts +13 -3
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +151 -49
- package/dist/client/client.js.map +1 -1
- package/dist/client/crypto/crypt.d.ts +6 -0
- package/dist/client/crypto/crypt.d.ts.map +1 -1
- package/dist/client/crypto/crypt.js +13 -7
- package/dist/client/crypto/crypt.js.map +1 -1
- package/dist/client/crypto/eax.d.ts +4 -0
- package/dist/client/crypto/eax.d.ts.map +1 -1
- package/dist/client/crypto/eax.js +27 -21
- package/dist/client/crypto/eax.js.map +1 -1
- package/dist/client/errors.d.ts +13 -0
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +23 -0
- package/dist/client/errors.js.map +1 -1
- package/dist/client/handshake/crypt-handshake.d.ts +2 -1
- package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/client/handshake/crypt-handshake.js +4 -2
- package/dist/client/handshake/crypt-handshake.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/throttle.d.ts +11 -0
- package/dist/client/throttle.d.ts.map +1 -1
- package/dist/client/throttle.js +38 -3
- package/dist/client/throttle.js.map +1 -1
- package/dist/client/transfer.d.ts +37 -4
- package/dist/client/transfer.d.ts.map +1 -1
- package/dist/client/transfer.js +155 -15
- package/dist/client/transfer.js.map +1 -1
- package/dist/client/transport/handler.d.ts.map +1 -1
- package/dist/client/transport/handler.js +47 -43
- package/dist/client/transport/handler.js.map +1 -1
- package/dist/core/api/clients.d.ts +15 -15
- package/dist/core/api/clients.d.ts.map +1 -1
- package/dist/core/api/clients.js.map +1 -1
- package/dist/core/api/command.d.ts +8 -6
- package/dist/core/api/command.d.ts.map +1 -1
- package/dist/core/api/command.js +1 -1
- package/dist/core/api/command.js.map +1 -1
- package/dist/core/api/files.d.ts +23 -6
- package/dist/core/api/files.d.ts.map +1 -1
- package/dist/core/api/files.js +12 -9
- package/dist/core/api/files.js.map +1 -1
- package/dist/core/api/groups.d.ts +17 -2
- package/dist/core/api/groups.d.ts.map +1 -1
- package/dist/core/api/groups.js +13 -11
- package/dist/core/api/groups.js.map +1 -1
- package/dist/core/api/parse.d.ts +5 -5
- package/dist/core/api/parse.d.ts.map +1 -1
- package/dist/core/api/parse.js +16 -15
- package/dist/core/api/parse.js.map +1 -1
- package/dist/core/command/request.d.ts +6 -0
- package/dist/core/command/request.d.ts.map +1 -1
- package/dist/core/command/request.js +12 -0
- package/dist/core/command/request.js.map +1 -1
- package/dist/core/errors.d.ts +46 -3
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +73 -20
- package/dist/core/errors.js.map +1 -1
- package/dist/core/events.d.ts +24 -8
- package/dist/core/events.d.ts.map +1 -1
- package/dist/core/internal.d.ts +1 -1
- package/dist/core/internal.d.ts.map +1 -1
- package/dist/core/internal.js +1 -1
- package/dist/core/internal.js.map +1 -1
- package/dist/core/notifications.d.ts +1 -1
- package/dist/core/notifications.js +9 -8
- package/dist/core/notifications.js.map +1 -1
- package/dist/core/reconnect.d.ts +16 -5
- package/dist/core/reconnect.d.ts.map +1 -1
- package/dist/core/reconnect.js +32 -4
- package/dist/core/reconnect.js.map +1 -1
- package/dist/core/types.d.ts +15 -16
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/query/client.d.ts +4 -0
- package/dist/query/client.d.ts.map +1 -1
- package/dist/query/client.js +145 -23
- package/dist/query/client.js.map +1 -1
- package/dist/query/errors.d.ts +20 -0
- package/dist/query/errors.d.ts.map +1 -1
- package/dist/query/errors.js +31 -0
- package/dist/query/errors.js.map +1 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/index.js.map +1 -1
- package/dist/query/lines.d.ts +11 -0
- package/dist/query/lines.d.ts.map +1 -1
- package/dist/query/lines.js +36 -6
- package/dist/query/lines.js.map +1 -1
- package/dist/query/options.d.ts +30 -0
- package/dist/query/options.d.ts.map +1 -1
- package/dist/query/options.js +7 -0
- package/dist/query/options.js.map +1 -1
- package/dist/query/reconnect.d.ts +5 -0
- package/dist/query/reconnect.d.ts.map +1 -1
- package/dist/query/reconnect.js +26 -7
- package/dist/query/reconnect.js.map +1 -1
- package/dist/query/ssh.js +9 -4
- package/dist/query/ssh.js.map +1 -1
- package/dist/query/stream.d.ts +2 -0
- package/dist/query/stream.d.ts.map +1 -1
- package/dist/query/stream.js +19 -1
- package/dist/query/stream.js.map +1 -1
- package/dist/query/transport.d.ts +12 -2
- package/dist/query/transport.d.ts.map +1 -1
- package/dist/query/web.d.ts +3 -1
- package/dist/query/web.d.ts.map +1 -1
- package/dist/query/web.js +82 -18
- package/dist/query/web.js.map +1 -1
- package/dist/stream/published.d.ts.map +1 -1
- package/dist/stream/published.js +61 -25
- package/dist/stream/published.js.map +1 -1
- package/dist/stream/streams.d.ts.map +1 -1
- package/dist/stream/streams.js +51 -10
- package/dist/stream/streams.js.map +1 -1
- package/dist/stream/wire.d.ts +2 -2
- package/dist/stream/wire.d.ts.map +1 -1
- package/dist/stream/wire.js +27 -7
- package/dist/stream/wire.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api/avatars.ts +15 -2
- package/src/client/api/files.ts +83 -23
- package/src/client/api/upload-checks.ts +96 -0
- package/src/client/api/voice.ts +5 -2
- package/src/client/client.ts +154 -42
- package/src/client/crypto/crypt.ts +15 -7
- package/src/client/crypto/eax.ts +27 -22
- package/src/client/errors.ts +25 -0
- package/src/client/handshake/crypt-handshake.ts +6 -3
- package/src/client/index.ts +1 -0
- package/src/client/throttle.ts +41 -3
- package/src/client/transfer.ts +172 -28
- package/src/client/transport/handler.ts +54 -46
- package/src/core/api/clients.ts +24 -21
- package/src/core/api/command.ts +9 -7
- package/src/core/api/files.ts +30 -16
- package/src/core/api/groups.ts +32 -13
- package/src/core/api/parse.ts +16 -15
- package/src/core/command/request.ts +18 -0
- package/src/core/errors.ts +74 -20
- package/src/core/events.ts +24 -8
- package/src/core/internal.ts +7 -1
- package/src/core/notifications.ts +9 -8
- package/src/core/reconnect.ts +60 -8
- package/src/core/types.ts +15 -16
- package/src/query/client.ts +144 -20
- package/src/query/errors.ts +34 -0
- package/src/query/index.ts +1 -0
- package/src/query/lines.ts +38 -6
- package/src/query/options.ts +36 -0
- package/src/query/reconnect.ts +27 -7
- package/src/query/ssh.ts +11 -4
- package/src/query/stream.ts +17 -1
- package/src/query/testing/live.ts +4 -4
- package/src/query/transport.ts +10 -2
- package/src/query/web.ts +76 -21
- package/src/stream/published.ts +70 -23
- package/src/stream/streams.ts +47 -13
- package/src/stream/wire.ts +30 -11
package/src/client/client.ts
CHANGED
|
@@ -50,6 +50,9 @@ import {
|
|
|
50
50
|
buildCommand,
|
|
51
51
|
buildCommandChain,
|
|
52
52
|
encodeRequest,
|
|
53
|
+
floodHoldMs,
|
|
54
|
+
FLOOD_RETRIES,
|
|
55
|
+
floodRetryDelayMs,
|
|
53
56
|
parseServerError,
|
|
54
57
|
resolveBackoffPolicy,
|
|
55
58
|
} from "../core/internal.js";
|
|
@@ -468,6 +471,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
468
471
|
this.#clientInitOptions = normalizeClientInitOptions(options);
|
|
469
472
|
this.#socketFactory = options.socketFactory;
|
|
470
473
|
this.#fileTransferSocketFactory = options.fileTransferSocketFactory;
|
|
474
|
+
// Refused here, not when the loop first runs: a bad policy would flood.
|
|
475
|
+
if (options.reconnect !== undefined) resolveBackoffPolicy(options.reconnect);
|
|
471
476
|
this.#reconnectOptions = options.reconnect;
|
|
472
477
|
this.#trace = options.trace === undefined ? undefined : { ...options.trace };
|
|
473
478
|
this.#viewStore = new ViewStore(
|
|
@@ -696,7 +701,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
696
701
|
* Leave the server and close the connection.
|
|
697
702
|
*
|
|
698
703
|
* Says goodbye first, waiting for the server's acknowledgement for a second
|
|
699
|
-
* at most, or less if `signal` aborts sooner. The
|
|
704
|
+
* at most, or less if `signal` aborts sooner. The goodbye is sent at once,
|
|
705
|
+
* past the flood throttle, and also by a client still connecting whose
|
|
706
|
+
* clientinit is out; commands still waiting for their turn reject with a
|
|
707
|
+
* NotConnectedError and are never sent. The connection is closed
|
|
700
708
|
* either way. A `connect()` still waiting for its handshake rejects with a
|
|
701
709
|
* NotConnectedError, and so do the commands still waiting for an answer.
|
|
702
710
|
* Resolves once `disconnected` has been delivered to the listeners, so one
|
|
@@ -728,13 +736,20 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
728
736
|
// still be on its way.
|
|
729
737
|
if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
|
|
730
738
|
|
|
731
|
-
|
|
739
|
+
// The server may hold a session for this client from the moment clientinit
|
|
740
|
+
// is out, which is as soon as the keys exist, well before Connected.
|
|
741
|
+
const holdsSession =
|
|
742
|
+
this.#status === ClientStatus.Connected ||
|
|
743
|
+
(this.#status === ClientStatus.Connecting && this.#crypt.cryptoInitComplete);
|
|
732
744
|
this.#status = ClientStatus.Disconnected;
|
|
745
|
+
// Whatever still waits for its turn is rejected and never sent; the
|
|
746
|
+
// goodbye does not wait for one.
|
|
747
|
+
this.#throttle.rejectAll(new NotConnectedError());
|
|
733
748
|
let left!: () => void;
|
|
734
749
|
this.#attempt.rejectAfter(new NotConnectedError(), new Promise<void>((r) => (left = r)));
|
|
735
750
|
|
|
736
751
|
const leaving = this.#leave(
|
|
737
|
-
|
|
752
|
+
holdsSession,
|
|
738
753
|
options.signal,
|
|
739
754
|
options.reasonMessage ?? "Shutdown",
|
|
740
755
|
).finally(left);
|
|
@@ -747,13 +762,13 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
747
762
|
}
|
|
748
763
|
|
|
749
764
|
async #leave(
|
|
750
|
-
|
|
765
|
+
holdsSession: boolean,
|
|
751
766
|
signal: AbortSignal | undefined,
|
|
752
767
|
reasonMessage: string,
|
|
753
768
|
): Promise<void> {
|
|
754
769
|
this.#logger.info("disconnecting from server");
|
|
755
770
|
|
|
756
|
-
if (
|
|
771
|
+
if (holdsSession) {
|
|
757
772
|
this.#callHook(this.#trace?.disconnectStart, () => ({}));
|
|
758
773
|
const goodbyeStarted = performance.now();
|
|
759
774
|
// The server answers clientdisconnect with a notifyclientleftview about
|
|
@@ -771,6 +786,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
771
786
|
}),
|
|
772
787
|
{ signal, timeoutMs: DISCONNECT_TIMEOUT_MS },
|
|
773
788
|
true,
|
|
789
|
+
true,
|
|
774
790
|
).then(
|
|
775
791
|
() => true,
|
|
776
792
|
() => false,
|
|
@@ -806,6 +822,11 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
806
822
|
*
|
|
807
823
|
* Rejects with a NotConnectedError unless the client is connected: while it
|
|
808
824
|
* is still connecting, the session's keys may not exist yet.
|
|
825
|
+
*
|
|
826
|
+
* A command the server refuses as flooding (`ErrorCode.ClientIsFlooding`)
|
|
827
|
+
* with a time to wait is sent again after it, and every other command waits
|
|
828
|
+
* too, up to three times and while its timeout allows; the refusal that
|
|
829
|
+
* remains rejects as it is.
|
|
809
830
|
*/
|
|
810
831
|
// oxlint-disable-next-line typescript/array-type -- the public API report spells this `[]`; respelling it changes the report
|
|
811
832
|
exec(command: string, options: CommandOptions = {}): Promise<Record<string, string>[]> {
|
|
@@ -873,18 +894,28 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
873
894
|
command: string,
|
|
874
895
|
options: CommandOptions,
|
|
875
896
|
internal: boolean,
|
|
897
|
+
goodbye = false,
|
|
876
898
|
): Promise<Array<Record<string, string>>> {
|
|
877
|
-
if (!commandChannel.hasSubscribers)
|
|
878
|
-
|
|
879
|
-
|
|
899
|
+
if (!commandChannel.hasSubscribers) {
|
|
900
|
+
return this.#execUntraced(command, options, internal, goodbye);
|
|
901
|
+
}
|
|
902
|
+
const trace: CommandTrace = {
|
|
903
|
+
command: parseCommand(command)?.name ?? "",
|
|
904
|
+
client: this,
|
|
905
|
+
};
|
|
906
|
+
return commandChannel.tracePromise(
|
|
907
|
+
() => this.#execUntraced(command, options, internal, goodbye),
|
|
908
|
+
trace,
|
|
909
|
+
);
|
|
880
910
|
}
|
|
881
911
|
|
|
882
912
|
async #execUntraced(
|
|
883
913
|
command: string,
|
|
884
914
|
options: CommandOptions,
|
|
885
915
|
internal: boolean,
|
|
916
|
+
goodbye: boolean,
|
|
886
917
|
): Promise<Array<Record<string, string>>> {
|
|
887
|
-
const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
|
|
918
|
+
const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, onSent } = options;
|
|
888
919
|
signal?.throwIfAborted();
|
|
889
920
|
// A command sent on no socket used to vanish and surface 10 seconds later
|
|
890
921
|
// as a timeout, which reads like a slow server rather than a usage error.
|
|
@@ -898,7 +929,8 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
898
929
|
const correlation = key === undefined ? undefined : parsed?.params[key];
|
|
899
930
|
const echo = spec?.echoKey === undefined ? undefined : parsed?.params[spec.echoKey];
|
|
900
931
|
|
|
901
|
-
|
|
932
|
+
let [rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
|
|
933
|
+
const deadline = performance.now() + timeoutMs;
|
|
902
934
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
903
935
|
let onAbort: (() => void) | undefined;
|
|
904
936
|
// Started before the throttle: the timeout bounds the whole command, its
|
|
@@ -911,19 +943,45 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
911
943
|
givenUp.catch(() => {});
|
|
912
944
|
const throttleAbort = new AbortController();
|
|
913
945
|
try {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
946
|
+
for (let refused = 0; ; refused++) {
|
|
947
|
+
const withRc = appendReturnCode(command, rc);
|
|
948
|
+
// The goodbye of a disconnect goes out at once: queued behind other
|
|
949
|
+
// commands it would not leave in time, and the server would keep the
|
|
950
|
+
// session.
|
|
951
|
+
if (!goodbye) await Promise.race([this.#throttle.wait(throttleAbort.signal), givenUp]);
|
|
952
|
+
if (!this.#canSend(internal)) throw new NotConnectedError();
|
|
953
|
+
this.#sendLine(withRc);
|
|
954
|
+
if (refused === 0) onSent?.();
|
|
955
|
+
|
|
956
|
+
const result = await Promise.race([answered, givenUp]);
|
|
957
|
+
// Refused as flooding with a time to wait, which the command can
|
|
958
|
+
// afford: nothing is sent until then, and the command, which the
|
|
959
|
+
// server did not run, goes out again.
|
|
960
|
+
const wait = result.err ? floodRetryDelayMs(result.err) : undefined;
|
|
961
|
+
const hold = wait === undefined ? undefined : floodHoldMs(wait);
|
|
962
|
+
if (
|
|
963
|
+
hold !== undefined &&
|
|
964
|
+
!goodbye &&
|
|
965
|
+
refused < FLOOD_RETRIES &&
|
|
966
|
+
hold < deadline - performance.now()
|
|
967
|
+
) {
|
|
968
|
+
this.#logger.debug("the server asked for a pause", {
|
|
969
|
+
command: parsed?.name,
|
|
970
|
+
waitMs: wait,
|
|
971
|
+
});
|
|
972
|
+
this.#throttle.hold(hold);
|
|
973
|
+
this.#cmdTrack.unregister(rc);
|
|
974
|
+
[rc, answered] = this.#cmdTrack.register(spec, correlation, echo);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (result.err) throw result.err;
|
|
978
|
+
// The server took the rename, so it is what this client now asks for.
|
|
979
|
+
if (!internal && parsed?.name === "clientupdate") {
|
|
980
|
+
const renamed = parsed.params["client_nickname"];
|
|
981
|
+
if (renamed !== undefined) this.#requestedNickname = renamed;
|
|
982
|
+
}
|
|
983
|
+
return result.data;
|
|
925
984
|
}
|
|
926
|
-
return result.data;
|
|
927
985
|
} finally {
|
|
928
986
|
// A command given up while it waited for its turn gives the turn back.
|
|
929
987
|
throttleAbort.abort();
|
|
@@ -1159,8 +1217,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1159
1217
|
}
|
|
1160
1218
|
|
|
1161
1219
|
/**
|
|
1162
|
-
* Send one encoded audio frame to the channel this client is in.
|
|
1163
|
-
*
|
|
1220
|
+
* Send one encoded audio frame to the channel this client is in. An empty
|
|
1221
|
+
* frame marks the end of speech: the server relays it, and listeners get a
|
|
1222
|
+
* `voiceData` event with empty `data`. Throws a NotConnectedError unless the
|
|
1223
|
+
* client is connected.
|
|
1164
1224
|
*/
|
|
1165
1225
|
sendVoice(data: Uint8Array, codec: Codec, options?: VoiceOptions): void {
|
|
1166
1226
|
// Reserved: VoiceOptions has no fields to read yet.
|
|
@@ -1213,7 +1273,10 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1213
1273
|
// A privilege key is spent by the join that used it: a reconnection must
|
|
1214
1274
|
// not present it again.
|
|
1215
1275
|
if (this.#clientInitOptions.privilegeKey !== "") {
|
|
1216
|
-
this.#clientInitOptions = {
|
|
1276
|
+
this.#clientInitOptions = {
|
|
1277
|
+
...this.#clientInitOptions,
|
|
1278
|
+
privilegeKey: "",
|
|
1279
|
+
};
|
|
1217
1280
|
}
|
|
1218
1281
|
// Settled before the hook runs, so that nothing it does can turn this
|
|
1219
1282
|
// attempt into a failed one; connect() resumes only after the hook returns.
|
|
@@ -1417,7 +1480,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1417
1480
|
this.#failConnection(
|
|
1418
1481
|
error instanceof HandshakeError
|
|
1419
1482
|
? error
|
|
1420
|
-
: new HandshakeError("the handshake failed on a malformed packet", {
|
|
1483
|
+
: new HandshakeError("the handshake failed on a malformed packet", {
|
|
1484
|
+
cause: error,
|
|
1485
|
+
}),
|
|
1421
1486
|
);
|
|
1422
1487
|
return;
|
|
1423
1488
|
}
|
|
@@ -1456,7 +1521,7 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1456
1521
|
return;
|
|
1457
1522
|
}
|
|
1458
1523
|
|
|
1459
|
-
if ((pType === 0 /* Voice */ || pType === 1) /* VoiceWhisper */ && p.data.length
|
|
1524
|
+
if ((pType === 0 /* Voice */ || pType === 1) /* VoiceWhisper */ && p.data.length >= 5) {
|
|
1460
1525
|
this.#handleVoicePacket(p.data);
|
|
1461
1526
|
return;
|
|
1462
1527
|
}
|
|
@@ -1469,6 +1534,12 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1469
1534
|
/**
|
|
1470
1535
|
* Parse an incoming S2C voice packet.
|
|
1471
1536
|
* Format: [VId: u16] [CId: u16] [Codec: u8] [Data: var]
|
|
1537
|
+
*
|
|
1538
|
+
* A packet with no data is how a speaker's last frame is followed: the
|
|
1539
|
+
* server relays the empty frame a client sends when it stops talking, and it
|
|
1540
|
+
* is delivered as an event with empty `data`. Measured on TeamSpeak 3 3.13.8
|
|
1541
|
+
* and TeamSpeak 6 6.0.0: the frames of `sendVoice` arrive as 45-byte packets
|
|
1542
|
+
* and an empty one as a 5-byte packet.
|
|
1472
1543
|
*/
|
|
1473
1544
|
#handleVoicePacket(payload: Uint8Array): void {
|
|
1474
1545
|
// Audio arrives fifty times a second per speaker; build no event nobody
|
|
@@ -1656,7 +1727,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1656
1727
|
#answerConnectionInfoRequest(): void {
|
|
1657
1728
|
const line = buildSetConnectionInfo(this.#handler.trafficSnapshot(), this.#handler.roundTrip);
|
|
1658
1729
|
this.#execNoWait(line, undefined, false).catch((error: unknown) => {
|
|
1659
|
-
this.#logger.debug("could not answer a connection info request", {
|
|
1730
|
+
this.#logger.debug("could not answer a connection info request", {
|
|
1731
|
+
error: String(error),
|
|
1732
|
+
});
|
|
1660
1733
|
});
|
|
1661
1734
|
}
|
|
1662
1735
|
|
|
@@ -1754,17 +1827,29 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1754
1827
|
this.#emitServerEvent({ name: "clientUpdated", payload: result.event });
|
|
1755
1828
|
break;
|
|
1756
1829
|
case "clientServerGroupAdded":
|
|
1757
|
-
this.#emitServerEvent({
|
|
1830
|
+
this.#emitServerEvent({
|
|
1831
|
+
name: "clientServerGroupAdded",
|
|
1832
|
+
payload: result.event,
|
|
1833
|
+
});
|
|
1758
1834
|
break;
|
|
1759
1835
|
case "clientServerGroupRemoved":
|
|
1760
|
-
this.#emitServerEvent({
|
|
1836
|
+
this.#emitServerEvent({
|
|
1837
|
+
name: "clientServerGroupRemoved",
|
|
1838
|
+
payload: result.event,
|
|
1839
|
+
});
|
|
1761
1840
|
break;
|
|
1762
1841
|
case "clientChannelGroupChanged":
|
|
1763
|
-
this.#emitServerEvent({
|
|
1842
|
+
this.#emitServerEvent({
|
|
1843
|
+
name: "clientChannelGroupChanged",
|
|
1844
|
+
payload: result.event,
|
|
1845
|
+
});
|
|
1764
1846
|
break;
|
|
1765
1847
|
case "channelCreated":
|
|
1766
1848
|
this.#viewStore.applyCreated(result.event);
|
|
1767
|
-
this.#emitServerEvent({
|
|
1849
|
+
this.#emitServerEvent({
|
|
1850
|
+
name: "channelCreated",
|
|
1851
|
+
payload: result.event,
|
|
1852
|
+
});
|
|
1768
1853
|
break;
|
|
1769
1854
|
case "channelEdited":
|
|
1770
1855
|
this.#viewStore.applyEdited(result.event);
|
|
@@ -1784,31 +1869,49 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1784
1869
|
break;
|
|
1785
1870
|
}
|
|
1786
1871
|
case "channelDescriptionChanged":
|
|
1787
|
-
this.#emitServerEvent({
|
|
1872
|
+
this.#emitServerEvent({
|
|
1873
|
+
name: "channelDescriptionChanged",
|
|
1874
|
+
payload: result.event,
|
|
1875
|
+
});
|
|
1788
1876
|
break;
|
|
1789
1877
|
case "channelPasswordChanged":
|
|
1790
|
-
this.#emitServerEvent({
|
|
1878
|
+
this.#emitServerEvent({
|
|
1879
|
+
name: "channelPasswordChanged",
|
|
1880
|
+
payload: result.event,
|
|
1881
|
+
});
|
|
1791
1882
|
break;
|
|
1792
1883
|
case "serverEdited":
|
|
1793
1884
|
this.#viewStore.patchServer(result.event.changes);
|
|
1794
1885
|
this.#emitServerEvent({ name: "serverEdited", payload: result.event });
|
|
1795
1886
|
break;
|
|
1796
1887
|
case "channelSubscribed":
|
|
1797
|
-
this.#emitServerEvent({
|
|
1888
|
+
this.#emitServerEvent({
|
|
1889
|
+
name: "channelSubscribed",
|
|
1890
|
+
payload: result.event,
|
|
1891
|
+
});
|
|
1798
1892
|
break;
|
|
1799
1893
|
case "channelUnsubscribed":
|
|
1800
1894
|
// The server does not say the clients of the channel left.
|
|
1801
1895
|
this.#viewStore.dropChannel(result.event.channelID);
|
|
1802
|
-
this.#emitServerEvent({
|
|
1896
|
+
this.#emitServerEvent({
|
|
1897
|
+
name: "channelUnsubscribed",
|
|
1898
|
+
payload: result.event,
|
|
1899
|
+
});
|
|
1803
1900
|
break;
|
|
1804
1901
|
case "pluginCommand":
|
|
1805
1902
|
this.#emitServerEvent({ name: "pluginCommand", payload: result.event });
|
|
1806
1903
|
break;
|
|
1807
1904
|
case "clientChatComposing":
|
|
1808
|
-
this.#emitServerEvent({
|
|
1905
|
+
this.#emitServerEvent({
|
|
1906
|
+
name: "clientChatComposing",
|
|
1907
|
+
payload: result.event,
|
|
1908
|
+
});
|
|
1809
1909
|
break;
|
|
1810
1910
|
case "clientChatClosed":
|
|
1811
|
-
this.#emitServerEvent({
|
|
1911
|
+
this.#emitServerEvent({
|
|
1912
|
+
name: "clientChatClosed",
|
|
1913
|
+
payload: result.event,
|
|
1914
|
+
});
|
|
1812
1915
|
break;
|
|
1813
1916
|
case "poked":
|
|
1814
1917
|
this.#emitServerEvent({ name: "poked", payload: result.event });
|
|
@@ -1970,7 +2073,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
1970
2073
|
try {
|
|
1971
2074
|
retry = (options.shouldRetry ?? defaultShouldRetry)(err, attempt);
|
|
1972
2075
|
} catch (error) {
|
|
1973
|
-
this.#logger.error("shouldRetry threw; not reconnecting", {
|
|
2076
|
+
this.#logger.error("shouldRetry threw; not reconnecting", {
|
|
2077
|
+
error: String(error),
|
|
2078
|
+
});
|
|
1974
2079
|
retry = false;
|
|
1975
2080
|
threw = true;
|
|
1976
2081
|
}
|
|
@@ -2030,7 +2135,9 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
2030
2135
|
// Connected, or connecting, by hand during the wait: nothing is left to
|
|
2031
2136
|
// retry, and an attempt now would only fail with AlreadyConnectedError.
|
|
2032
2137
|
if (this.#status !== ClientStatus.Disconnected) {
|
|
2033
|
-
this.#logger.info("reconnect loop ended: connected by other means", {
|
|
2138
|
+
this.#logger.info("reconnect loop ended: connected by other means", {
|
|
2139
|
+
attempt,
|
|
2140
|
+
});
|
|
2034
2141
|
return;
|
|
2035
2142
|
}
|
|
2036
2143
|
|
|
@@ -2059,12 +2166,17 @@ export class Client implements CommandExecutor, AsyncDisposable {
|
|
|
2059
2166
|
: new TeamspeakError(String(retryErr), { cause: retryErr });
|
|
2060
2167
|
}
|
|
2061
2168
|
}
|
|
2062
|
-
this.#logger.warn("reconnect attempts exhausted", {
|
|
2169
|
+
this.#logger.warn("reconnect attempts exhausted", {
|
|
2170
|
+
attempts: policy.maxAttempts,
|
|
2171
|
+
});
|
|
2063
2172
|
stoppedBy = { cause: err };
|
|
2064
2173
|
} finally {
|
|
2065
2174
|
if (stoppedBy !== undefined) {
|
|
2066
2175
|
const { cause } = stoppedBy;
|
|
2067
|
-
this.#callHook(this.#trace?.reconnectStopped, () => ({
|
|
2176
|
+
this.#callHook(this.#trace?.reconnectStopped, () => ({
|
|
2177
|
+
attempts: made,
|
|
2178
|
+
cause,
|
|
2179
|
+
}));
|
|
2068
2180
|
}
|
|
2069
2181
|
this.#reconnecting = false;
|
|
2070
2182
|
this.#loopAttempt = 0;
|
|
@@ -140,18 +140,26 @@ export class Crypt {
|
|
|
140
140
|
this.setSharedSecret(alphaBytes, betaBytes, sharedSecret);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Build the IV from `alpha` and `beta` XORed into `sharedKey` and complete
|
|
145
|
+
* crypto init. The values come from the server, so a length that does not fit
|
|
146
|
+
* is refused rather than padded: alpha must be 10 bytes, beta at least one,
|
|
147
|
+
* and the key as long as the IV, 10 bytes plus beta.
|
|
148
|
+
*/
|
|
143
149
|
setSharedSecret(alpha: Uint8Array, beta: Uint8Array, sharedKey: Uint8Array): void {
|
|
150
|
+
if (
|
|
151
|
+
alpha.length !== IV_ALPHA_SIZE ||
|
|
152
|
+
beta.length === 0 ||
|
|
153
|
+
IV_ALPHA_SIZE + beta.length > sharedKey.length
|
|
154
|
+
) {
|
|
155
|
+
throw new HandshakeError("alpha, beta and the shared secret do not fit together");
|
|
156
|
+
}
|
|
144
157
|
this.#ivStruct = new Uint8Array(IV_ALPHA_SIZE + beta.length);
|
|
145
158
|
for (let i = 0; i < IV_ALPHA_SIZE; i++) {
|
|
146
|
-
|
|
147
|
-
const b = alpha[i];
|
|
148
|
-
this.#ivStruct[i] = ((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
|
|
159
|
+
this.#ivStruct[i] = (sharedKey[i]! ^ alpha[i]!) & 0xff;
|
|
149
160
|
}
|
|
150
161
|
for (let i = 0; i < beta.length; i++) {
|
|
151
|
-
|
|
152
|
-
const b = beta[i];
|
|
153
|
-
this.#ivStruct[IV_ALPHA_SIZE + i] =
|
|
154
|
-
((a !== undefined ? a : 0) ^ (b !== undefined ? b : 0)) & 0xff;
|
|
162
|
+
this.#ivStruct[IV_ALPHA_SIZE + i] = (sharedKey[IV_ALPHA_SIZE + i]! ^ beta[i]!) & 0xff;
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
// Keys derive from the IV: any cached before it changed belong to another
|
package/src/client/crypto/eax.ts
CHANGED
|
@@ -81,9 +81,13 @@ function aesCtr(key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array {
|
|
|
81
81
|
|
|
82
82
|
// ---- AES-CMAC (RFC 4493) ----------------------------------------------------
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The CMAC subkeys of each key, derived once: a session uses one key for every
|
|
86
|
+
* packet, and deriving them costs a cipher of its own.
|
|
87
|
+
*/
|
|
88
|
+
const subkeyCache = new WeakMap<Uint8Array, [Uint8Array, Uint8Array]>();
|
|
89
|
+
|
|
84
90
|
function aesEcb(key: Uint8Array, block: Uint8Array): Uint8Array {
|
|
85
|
-
// Node.js does not expose ECB directly; simulate via CTR with zero IV and
|
|
86
|
-
// a zero-input (CTR of 0-block XOR with input = ECB)
|
|
87
91
|
const cipher = createCipheriv("aes-128-ecb", Buffer.from(key), null);
|
|
88
92
|
cipher.setAutoPadding(false);
|
|
89
93
|
return Buffer.concat([cipher.update(Buffer.from(block)), cipher.final()]);
|
|
@@ -110,6 +114,8 @@ const RB = new Uint8Array(EAX_BLOCK_SIZE);
|
|
|
110
114
|
RB[EAX_BLOCK_SIZE - 1] = 0x87;
|
|
111
115
|
|
|
112
116
|
function generateSubkeys(key: Uint8Array): [Uint8Array, Uint8Array] {
|
|
117
|
+
const cached = subkeyCache.get(key);
|
|
118
|
+
if (cached !== undefined) return cached;
|
|
113
119
|
const zero = new Uint8Array(EAX_BLOCK_SIZE);
|
|
114
120
|
const L = aesEcb(key, zero);
|
|
115
121
|
|
|
@@ -119,34 +125,33 @@ function generateSubkeys(key: Uint8Array): [Uint8Array, Uint8Array] {
|
|
|
119
125
|
let K2 = shiftLeft1(K1);
|
|
120
126
|
if ((K1[0]! & 0x80) !== 0) K2 = xorBlock(K2, RB);
|
|
121
127
|
|
|
122
|
-
|
|
128
|
+
const subkeys: [Uint8Array, Uint8Array] = [K1, K2];
|
|
129
|
+
subkeyCache.set(key, subkeys);
|
|
130
|
+
return subkeys;
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
const ZERO_IV = Buffer.alloc(EAX_BLOCK_SIZE);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* AES-CMAC: the last block of AES-CBC with a zero IV over the message, its last
|
|
137
|
+
* block padded and XORed with a subkey. One cipher for the whole message.
|
|
138
|
+
*/
|
|
125
139
|
export function aesCmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
|
126
140
|
const [K1, K2] = generateSubkeys(key);
|
|
127
141
|
|
|
128
142
|
const n = Math.max(1, Math.ceil(message.length / EAX_BLOCK_SIZE));
|
|
129
143
|
const lastBlockComplete = message.length > 0 && message.length % EAX_BLOCK_SIZE === 0;
|
|
130
144
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
for (let i = 0; i < n - 1; i++) {
|
|
134
|
-
const block = message.slice(i * EAX_BLOCK_SIZE, (i + 1) * EAX_BLOCK_SIZE);
|
|
135
|
-
X = Uint8Array.from(aesEcb(key, xorBlock(X, block)));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const lastBlock = new Uint8Array(EAX_BLOCK_SIZE);
|
|
145
|
+
const padded = Buffer.alloc(n * EAX_BLOCK_SIZE);
|
|
146
|
+
padded.set(message);
|
|
139
147
|
const lastStart = (n - 1) * EAX_BLOCK_SIZE;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (lastBlockComplete) {
|
|
145
|
-
Mn = xorBlock(lastBlock, K1);
|
|
146
|
-
} else {
|
|
147
|
-
if (lastSlice.length < EAX_BLOCK_SIZE) lastBlock[lastSlice.length] = 0x80;
|
|
148
|
-
Mn = xorBlock(lastBlock, K2);
|
|
149
|
-
}
|
|
148
|
+
if (!lastBlockComplete) padded[message.length] = 0x80;
|
|
149
|
+
const subkey = lastBlockComplete ? K1 : K2;
|
|
150
|
+
for (let i = 0; i < EAX_BLOCK_SIZE; i++)
|
|
151
|
+
padded[lastStart + i] = padded[lastStart + i]! ^ subkey[i]!;
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), ZERO_IV);
|
|
154
|
+
cipher.setAutoPadding(false);
|
|
155
|
+
const out = cipher.update(padded);
|
|
156
|
+
return Uint8Array.from(out.subarray(out.length - EAX_BLOCK_SIZE));
|
|
152
157
|
}
|
package/src/client/errors.ts
CHANGED
|
@@ -195,6 +195,31 @@ export class ConnectionTimeoutError extends TeamspeakError {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The server's command stream broke: a command packet stayed missing for
|
|
200
|
+
* seconds while later ones arrived, although the server sends one again until
|
|
201
|
+
* it is acknowledged. The connection is ended rather than carry on without the
|
|
202
|
+
* missing command, which would drop an answer or a notification and could
|
|
203
|
+
* leave the view out of step with the server's; a reconnect starts from a
|
|
204
|
+
* consistent state.
|
|
205
|
+
*/
|
|
206
|
+
export class ConnectionStalledError extends TeamspeakError {
|
|
207
|
+
/** {@inheritDoc @honeybbq/teamspeak!TeamspeakError.kind} */
|
|
208
|
+
static override readonly kind: string = "ConnectionStalledError";
|
|
209
|
+
static {
|
|
210
|
+
Object.defineProperty(this.prototype, "name", {
|
|
211
|
+
value: "ConnectionStalledError",
|
|
212
|
+
writable: true,
|
|
213
|
+
configurable: true,
|
|
214
|
+
enumerable: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
constructor() {
|
|
219
|
+
super("connection stalled: a command from the server never arrived");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
198
223
|
/**
|
|
199
224
|
* The server ended this client's session: a kick, a ban, or the server
|
|
200
225
|
* stopping. The default reconnect policy retries a server that stopped, but
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { buildCommandOrdered } from "../../core/internal.js";
|
|
3
3
|
import { MAX_RSA_CHALLENGE_LEVEL, type Crypt } from "../crypto/crypt.js";
|
|
4
|
-
import {
|
|
4
|
+
import { PuzzleTooHardError } from "../errors.js";
|
|
5
5
|
|
|
6
6
|
export const INIT_VERSION = 1566914096; // 3.5.0 [Stable]
|
|
7
7
|
|
|
@@ -35,7 +35,8 @@ export function puzzleOf(crypt: Crypt): { readonly level: number; readonly durat
|
|
|
35
35
|
/**
|
|
36
36
|
* Handle the TS3INIT1 handshake steps.
|
|
37
37
|
* Returns the response bytes to send, or null if nothing should be sent.
|
|
38
|
-
*
|
|
38
|
+
* A step that cannot be answered is ignored (null); a puzzle out of range
|
|
39
|
+
* throws a PuzzleTooHardError.
|
|
39
40
|
*
|
|
40
41
|
* The puzzle step is the only one that takes time, so a challenge not answered
|
|
41
42
|
* before returns a promise of the response, which gives the event loop back
|
|
@@ -56,7 +57,9 @@ export function processInit1(
|
|
|
56
57
|
if (data === null || (data.length >= 1 && data[0] === 0x7f)) {
|
|
57
58
|
return buildInit1StartPacket();
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
+
// Init1 is not authenticated: a step that cannot be answered, an empty one
|
|
61
|
+
// included, is ignored, and the attempt goes on with the real server's.
|
|
62
|
+
if (data.length === 0) return null;
|
|
60
63
|
|
|
61
64
|
switch (data[0]) {
|
|
62
65
|
case 0:
|
package/src/client/index.ts
CHANGED
package/src/client/throttle.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
interface Waiter {
|
|
2
2
|
readonly resolve: () => void;
|
|
3
|
+
readonly reject: (reason: unknown) => void;
|
|
3
4
|
readonly signal: AbortSignal | undefined;
|
|
4
5
|
readonly onAbort: () => void;
|
|
5
6
|
}
|
|
@@ -18,6 +19,8 @@ export class CommandThrottle {
|
|
|
18
19
|
|
|
19
20
|
#tokens = 5.0;
|
|
20
21
|
#lastUpdate = performance.now();
|
|
22
|
+
/** Until when nothing is released: the server asked this client to wait. */
|
|
23
|
+
#holdUntil = 0;
|
|
21
24
|
readonly #waiters: Waiter[] = [];
|
|
22
25
|
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
23
26
|
|
|
@@ -29,13 +32,14 @@ export class CommandThrottle {
|
|
|
29
32
|
wait(signal?: AbortSignal): Promise<void> {
|
|
30
33
|
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
31
34
|
this.#refill();
|
|
32
|
-
if (this.#waiters.length === 0 && this.#tokens >= 1.0) {
|
|
35
|
+
if (this.#waiters.length === 0 && this.#tokens >= 1.0 && performance.now() >= this.#holdUntil) {
|
|
33
36
|
this.#tokens -= 1.0;
|
|
34
37
|
return Promise.resolve();
|
|
35
38
|
}
|
|
36
39
|
return new Promise<void>((resolve, reject) => {
|
|
37
40
|
const waiter: Waiter = {
|
|
38
41
|
resolve,
|
|
42
|
+
reject,
|
|
39
43
|
signal,
|
|
40
44
|
onAbort: () => {
|
|
41
45
|
const index = this.#waiters.indexOf(waiter);
|
|
@@ -50,9 +54,35 @@ export class CommandThrottle {
|
|
|
50
54
|
});
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Release nothing for `ms` milliseconds, then go on at the usual rate with no
|
|
59
|
+
* burst saved up: the server refused a command as flooding and said when to
|
|
60
|
+
* try again, and anything sent sooner is refused too and adds to the debt.
|
|
61
|
+
*/
|
|
62
|
+
hold(ms: number): void {
|
|
63
|
+
this.#refill();
|
|
64
|
+
this.#holdUntil = Math.max(this.#holdUntil, performance.now() + ms);
|
|
65
|
+
this.#tokens = 0;
|
|
66
|
+
this.#stopTimer();
|
|
67
|
+
this.#schedule();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reject every waiter still in line with `reason`, so none of them is ever
|
|
72
|
+
* released to send. Commands that ask afterwards wait as before.
|
|
73
|
+
*/
|
|
74
|
+
rejectAll(reason: unknown): void {
|
|
75
|
+
this.#stopTimer();
|
|
76
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
77
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
78
|
+
waiter.reject(reason);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
#refill(): void {
|
|
54
83
|
const now = performance.now();
|
|
55
|
-
|
|
84
|
+
// Nothing accrues while held.
|
|
85
|
+
const elapsed = Math.max(0, now - Math.max(this.#lastUpdate, this.#holdUntil)) / 1000;
|
|
56
86
|
this.#tokens = Math.min(
|
|
57
87
|
this.#tokens + elapsed * CommandThrottle.TOKEN_RATE,
|
|
58
88
|
CommandThrottle.TOKEN_MAX,
|
|
@@ -63,10 +93,13 @@ export class CommandThrottle {
|
|
|
63
93
|
/** Arm the timer for when the next token is due, unless it already is. */
|
|
64
94
|
#schedule(): void {
|
|
65
95
|
if (this.#timer !== undefined || this.#waiters.length === 0) return;
|
|
66
|
-
const
|
|
96
|
+
const forTokens =
|
|
67
97
|
this.#tokens >= 1.0
|
|
68
98
|
? 0
|
|
69
99
|
: Math.ceil(((1.0 - this.#tokens) / CommandThrottle.TOKEN_RATE) * 1000) + 10;
|
|
100
|
+
const held = Math.ceil(this.#holdUntil - performance.now());
|
|
101
|
+
// Tokens accrue only once the hold is over.
|
|
102
|
+
const waitMs = Math.max(0, held) + forTokens;
|
|
70
103
|
this.#timer = setTimeout(() => {
|
|
71
104
|
this.#timer = undefined;
|
|
72
105
|
this.#release();
|
|
@@ -81,6 +114,11 @@ export class CommandThrottle {
|
|
|
81
114
|
/** Hand the tokens there are to the waiters at the head of the line. */
|
|
82
115
|
#release(): void {
|
|
83
116
|
this.#refill();
|
|
117
|
+
// Woken early, or the hold was extended meanwhile.
|
|
118
|
+
if (performance.now() < this.#holdUntil) {
|
|
119
|
+
this.#schedule();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
84
122
|
while (this.#waiters.length > 0 && this.#tokens >= 1.0) {
|
|
85
123
|
this.#tokens -= 1.0;
|
|
86
124
|
const waiter = this.#waiters.shift()!;
|