@honeybbq/teamspeak 1.0.0-beta.21 → 1.0.0-beta.23

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.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/client/crypto/identity.d.ts +15 -4
  3. package/dist/client/crypto/identity.d.ts.map +1 -1
  4. package/dist/client/crypto/identity.js +19 -1
  5. package/dist/client/crypto/identity.js.map +1 -1
  6. package/dist/client/crypto/index.d.ts +1 -1
  7. package/dist/client/crypto/index.d.ts.map +1 -1
  8. package/dist/client/crypto/index.js +1 -0
  9. package/dist/client/crypto/index.js.map +1 -1
  10. package/dist/client/discovery/resolver.d.ts +35 -2
  11. package/dist/client/discovery/resolver.d.ts.map +1 -1
  12. package/dist/client/discovery/resolver.js +129 -45
  13. package/dist/client/discovery/resolver.js.map +1 -1
  14. package/dist/client/handshake/license.js +1 -0
  15. package/dist/client/handshake/license.js.map +1 -1
  16. package/dist/client/index.d.ts +8 -5
  17. package/dist/client/index.d.ts.map +1 -1
  18. package/dist/client/index.js +5 -2
  19. package/dist/client/index.js.map +1 -1
  20. package/dist/client/trace.d.ts +4 -1
  21. package/dist/client/trace.d.ts.map +1 -1
  22. package/dist/client/transfer.d.ts +10 -1
  23. package/dist/client/transfer.d.ts.map +1 -1
  24. package/dist/client/transfer.js +10 -0
  25. package/dist/client/transfer.js.map +1 -1
  26. package/dist/client/transport/handler.d.ts.map +1 -1
  27. package/dist/client/transport/handler.js +3 -3
  28. package/dist/client/transport/handler.js.map +1 -1
  29. package/dist/client/transport/packet.d.ts +5 -0
  30. package/dist/client/transport/packet.d.ts.map +1 -1
  31. package/dist/client/transport/packet.js +9 -1
  32. package/dist/client/transport/packet.js.map +1 -1
  33. package/dist/client/types.d.ts +28 -1
  34. package/dist/client/types.d.ts.map +1 -1
  35. package/dist/client/types.js +21 -0
  36. package/dist/client/types.js.map +1 -1
  37. package/dist/client/view.d.ts +24 -8
  38. package/dist/client/view.d.ts.map +1 -1
  39. package/dist/client/view.js +24 -1
  40. package/dist/client/view.js.map +1 -1
  41. package/dist/core/api/channels.d.ts +3 -3
  42. package/dist/core/api/channels.d.ts.map +1 -1
  43. package/dist/core/api/clients.d.ts +13 -1
  44. package/dist/core/api/clients.d.ts.map +1 -1
  45. package/dist/core/api/clients.js +12 -0
  46. package/dist/core/api/clients.js.map +1 -1
  47. package/dist/core/api/command.d.ts +11 -3
  48. package/dist/core/api/command.d.ts.map +1 -1
  49. package/dist/core/api/command.js +8 -2
  50. package/dist/core/api/command.js.map +1 -1
  51. package/dist/core/api/groups.d.ts +2 -1
  52. package/dist/core/api/groups.d.ts.map +1 -1
  53. package/dist/core/api/groups.js.map +1 -1
  54. package/dist/core/api/instance.d.ts +5 -3
  55. package/dist/core/api/instance.d.ts.map +1 -1
  56. package/dist/core/api/instance.js.map +1 -1
  57. package/dist/core/api/server.d.ts +40 -10
  58. package/dist/core/api/server.d.ts.map +1 -1
  59. package/dist/core/api/server.js +2 -2
  60. package/dist/core/api/server.js.map +1 -1
  61. package/dist/core/command-name.d.ts +6 -0
  62. package/dist/core/command-name.d.ts.map +1 -1
  63. package/dist/core/command-name.js +6 -0
  64. package/dist/core/command-name.js.map +1 -1
  65. package/dist/core/enums.d.ts +94 -4
  66. package/dist/core/enums.d.ts.map +1 -1
  67. package/dist/core/enums.js +91 -4
  68. package/dist/core/enums.js.map +1 -1
  69. package/dist/core/index.d.ts +3 -3
  70. package/dist/core/index.d.ts.map +1 -1
  71. package/dist/core/index.js +2 -2
  72. package/dist/core/index.js.map +1 -1
  73. package/dist/core/property-name.d.ts +3 -3
  74. package/dist/core/property-name.d.ts.map +1 -1
  75. package/dist/core/property-name.js +3 -3
  76. package/dist/core/property-name.js.map +1 -1
  77. package/dist/core/types.d.ts +14 -4
  78. package/dist/core/types.d.ts.map +1 -1
  79. package/dist/core/types.js +16 -4
  80. package/dist/core/types.js.map +1 -1
  81. package/dist/query/client.d.ts +55 -1
  82. package/dist/query/client.d.ts.map +1 -1
  83. package/dist/query/client.js +53 -0
  84. package/dist/query/client.js.map +1 -1
  85. package/dist/query/index.d.ts +2 -2
  86. package/dist/query/index.d.ts.map +1 -1
  87. package/dist/query/index.js +1 -1
  88. package/dist/query/index.js.map +1 -1
  89. package/dist/query/options.d.ts +21 -1
  90. package/dist/query/options.d.ts.map +1 -1
  91. package/dist/query/options.js +34 -5
  92. package/dist/query/options.js.map +1 -1
  93. package/dist/stream/index.d.ts +2 -1
  94. package/dist/stream/index.d.ts.map +1 -1
  95. package/dist/stream/index.js +1 -0
  96. package/dist/stream/index.js.map +1 -1
  97. package/dist/stream/types.d.ts +243 -25
  98. package/dist/stream/types.d.ts.map +1 -1
  99. package/dist/stream/types.js +235 -1
  100. package/dist/stream/types.js.map +1 -1
  101. package/package.json +12 -10
  102. package/src/client/crypto/identity.ts +16 -5
  103. package/src/client/crypto/index.ts +1 -1
  104. package/src/client/discovery/resolver.ts +161 -53
  105. package/src/client/handshake/license.ts +1 -0
  106. package/src/client/index.ts +13 -5
  107. package/src/client/trace.ts +4 -1
  108. package/src/client/transfer.ts +11 -1
  109. package/src/client/transport/handler.ts +3 -2
  110. package/src/client/transport/packet.ts +10 -1
  111. package/src/client/types.ts +30 -1
  112. package/src/client/view.ts +24 -9
  113. package/src/core/api/channels.ts +3 -3
  114. package/src/core/api/clients.ts +14 -1
  115. package/src/core/api/command.ts +16 -5
  116. package/src/core/api/groups.ts +2 -1
  117. package/src/core/api/instance.ts +5 -3
  118. package/src/core/api/server.ts +39 -10
  119. package/src/core/command-name.ts +6 -0
  120. package/src/core/enums.ts +111 -4
  121. package/src/core/index.ts +12 -2
  122. package/src/core/property-name.ts +3 -3
  123. package/src/core/types.ts +19 -8
  124. package/src/query/client.ts +68 -6
  125. package/src/query/index.ts +1 -2
  126. package/src/query/options.ts +26 -13
  127. package/src/stream/index.ts +14 -8
  128. package/src/stream/types.ts +321 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honeybbq/teamspeak",
3
- "version": "1.0.0-beta.21",
3
+ "version": "1.0.0-beta.23",
4
4
  "description": "TeamSpeak SDK for TypeScript: an independent voice client for Node.js, and the typed commands, ids and errors every connection shares",
5
5
  "keywords": [
6
6
  "client",
@@ -71,23 +71,24 @@
71
71
  "@changesets/changelog-github": "^1.0.1",
72
72
  "@changesets/cli": "^3.0.3",
73
73
  "@honeybbq/teamspeak": "link:.",
74
- "@microsoft/api-extractor": "7.59.4",
75
- "@types/node": "^26.6.5",
74
+ "@microsoft/api-extractor": "7.60.1",
75
+ "@types/node": "^26.6.6",
76
76
  "@types/ssh2": "^1.15.6",
77
+ "@vitest/coverage-v8": "5.0.5",
77
78
  "fast-check": "^4.10.2",
78
- "oxfmt": "^0.73.0",
79
- "oxlint": "^1.88.0",
80
- "oxlint-tsgolint": "^7.0.2004",
79
+ "oxfmt": "^0.75.0",
80
+ "oxlint": "^1.90.0",
81
+ "oxlint-tsgolint": "^7.0.2005",
81
82
  "publint": "^0.3.25",
82
83
  "ssh2": "^1.17.0",
83
- "tsx": "^4.21.0",
84
+ "tsx": "^4.23.15",
84
85
  "typedoc": "0.28.20",
85
86
  "typedoc-material-theme": "^1.4.1",
86
87
  "typescript": "^7.0.2",
87
- "typescript-api": "npm:typescript@^5.9.3",
88
- "vite": "^8.3.4",
88
+ "typescript-api": "npm:typescript@^6.0.3",
89
+ "vite": "^8.3.5",
89
90
  "vitest": "^5.0.5",
90
- "werift": "^0.25.1"
91
+ "werift": "^0.25.2"
91
92
  },
92
93
  "peerDependencies": {
93
94
  "ssh2": "^1.17.0"
@@ -103,6 +104,7 @@
103
104
  "scripts": {
104
105
  "build": "tsc -p tsconfig.build.json",
105
106
  "test": "vitest run --project @honeybbq/teamspeak --project scripts",
107
+ "test:coverage": "vitest run --coverage --project @honeybbq/teamspeak --project scripts",
106
108
  "test:watch": "vitest",
107
109
  "lint": "oxlint --deny-warnings .",
108
110
  "fmt": "oxfmt .",
@@ -246,6 +246,13 @@ function generateKey(): KeyObject {
246
246
  return generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey;
247
247
  }
248
248
 
249
+ /** The values of the table below, before freezing. */
250
+ const uidAlgorithmValues = {
251
+ /** SHA-1, 20 bytes: a TeamSpeak 3 server. */
252
+ SHA1: "sha1",
253
+ /** SHA-256, 32 bytes: a TeamSpeak 6 server. */
254
+ SHA256: "sha256",
255
+ } as const;
249
256
  /**
250
257
  * The hash a server uses for `client_unique_identifier`, keyed by the server's
251
258
  * major version.
@@ -254,16 +261,20 @@ function generateKey(): KeyObject {
254
261
  * 6 switched to SHA-256 (32 bytes). The algorithm is not negotiable on the
255
262
  * wire, so a client that guesses wrong computes a UID that matches nothing the
256
263
  * server ever sends — self-identification by UID silently fails.
257
- *
258
- * May gain members in a minor release, should a server generation change the
259
- * hash again: a `switch` over it needs a default case.
260
264
  */
261
- export type UIDAlgorithm = "sha1" | "sha256";
265
+ export const UIDAlgorithm: typeof uidAlgorithmValues =
266
+ /* @__PURE__ */ Object.freeze(uidAlgorithmValues);
267
+ /**
268
+ * One of {@link UIDAlgorithm}. May gain members in a minor release, should a
269
+ * server generation change the hash again: a `switch` over it needs a default
270
+ * case.
271
+ */
272
+ export type UIDAlgorithm = (typeof UIDAlgorithm)[keyof typeof UIDAlgorithm];
262
273
 
263
274
  /** Pick the UID hash from a `virtualserver_version` string, e.g. "6.0.0-beta13.1". */
264
275
  export function uidAlgorithmForVersion(version: string): UIDAlgorithm {
265
276
  const major = Number.parseInt(version, 10);
266
- return Number.isNaN(major) || major < 6 ? "sha1" : "sha256";
277
+ return Number.isNaN(major) || major < 6 ? UIDAlgorithm.SHA1 : UIDAlgorithm.SHA256;
267
278
  }
268
279
 
269
280
  /**
@@ -9,7 +9,7 @@ export {
9
9
  hash512,
10
10
  importPublicKey,
11
11
  } from "./identity.js";
12
- export type { UIDAlgorithm } from "./identity.js";
12
+ export { UIDAlgorithm } from "./identity.js";
13
13
  export { Crypt } from "./crypt.js";
14
14
  export type { KeyNonce } from "./crypt.js";
15
15
  export { EAX, aesCmac } from "./eax.js";
@@ -1,11 +1,15 @@
1
1
  import { resolveSrv } from "node:dns/promises";
2
2
  import { createConnection } from "node:net";
3
- import type { ResolvedAddr } from "../types.js";
3
+ import { ResolveSource } from "../types.js";
4
+ import type { AddrResolver, ResolvedAddr } from "../types.js";
4
5
  import { isIpAddress, joinHostPort, splitHostPort } from "../address.js";
5
6
  import { abortable } from "../abort.js";
7
+ import { TeamspeakError } from "@honeybbq/teamspeak";
6
8
  import { InvalidAddressError } from "../errors.js";
7
9
 
8
10
  const TS_DNS_DEFAULT_PORT = 41144;
11
+ /** What a TSDNS entry has for a port to say: the one the user gave. */
12
+ const PORT_PLACEHOLDER = ":$PORT";
9
13
  const NICKNAME_LOOKUP_URL = "https://named.myteamspeak.com/lookup";
10
14
  const CACHE_TTL_MS = 10 * 60 * 1000;
11
15
  /**
@@ -29,14 +33,31 @@ export const MAX_DISCOVERY_RESPONSE: number = 8 * 1024;
29
33
  export interface ResolverOptions {
30
34
  /** Look dot-less names up at named.myteamspeak.com; off by default. */
31
35
  readonly nicknameLookup?: boolean | undefined;
36
+ /**
37
+ * The nickname lookup endpoint, an http: or https: URL; the default is
38
+ * `https://named.myteamspeak.com/lookup`. Used only with `nicknameLookup`:
39
+ * setting it does not turn the lookup on. For a test double or a mirror of
40
+ * the service. The name is sent as the `name` query parameter, and the first
41
+ * line of the answer is the address. A URL that is not http: or https: throws
42
+ * a TypeError.
43
+ */
44
+ readonly nicknameService?: string | URL | undefined;
32
45
  }
33
46
 
34
- export class Resolver {
47
+ /**
48
+ * The resolver a Client uses unless it is given another. It resolves as the
49
+ * TeamSpeak application does, so on its own it shows where an address leads:
50
+ * `await new Resolver().resolve("ts.example.com")`. Answers are kept for ten
51
+ * minutes.
52
+ */
53
+ export class Resolver implements AddrResolver {
35
54
  readonly #cache = new Map<string, ResolvedAddr[]>();
36
55
  readonly #nicknameLookup: boolean;
56
+ readonly #nicknameService: URL;
37
57
 
38
58
  constructor(options: ResolverOptions = {}) {
39
59
  this.#nicknameLookup = options.nicknameLookup ?? false;
60
+ this.#nicknameService = parseNicknameService(options.nicknameService ?? NICKNAME_LOOKUP_URL);
40
61
  }
41
62
 
42
63
  /**
@@ -58,7 +79,7 @@ export class Resolver {
58
79
  options.signal?.throwIfAborted();
59
80
  if (!budget.aborted) throw error;
60
81
  const { host, port } = splitHostPort(inputAddr);
61
- return [{ addr: joinHostPort(host, port), source: "Direct" }];
82
+ return [{ addr: joinHostPort(host, port), source: ResolveSource.Direct }];
62
83
  }
63
84
  }
64
85
 
@@ -73,14 +94,14 @@ export class Resolver {
73
94
  const { host, port } = splitHostPort(inputAddr);
74
95
 
75
96
  if (isIpAddress(host)) {
76
- return [{ addr: joinHostPort(host, port), source: "Direct" }];
97
+ return [{ addr: joinHostPort(host, port), source: ResolveSource.Direct }];
77
98
  }
78
99
 
79
100
  // A dot-less name may be a server nickname, which only the TeamSpeak
80
101
  // service can answer: asking it sends the name to a third party, so it is
81
102
  // opt-in. Otherwise the name is resolved like any host.
82
103
  if (nicknames && this.#nicknameLookup && !host.includes(".") && host !== "localhost") {
83
- const nickAddr = await resolveNickname(host, signal);
104
+ const nickAddr = await resolveNickname(host, this.#nicknameService, signal);
84
105
  signal.throwIfAborted();
85
106
  if (nickAddr) return this.#resolve(nickAddr, signal, false);
86
107
  }
@@ -92,20 +113,28 @@ export class Resolver {
92
113
  const domainList = getDomainList(host);
93
114
 
94
115
  // TSDNS via SRV _tsdns._tcp.<domain>
95
- const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, signal);
96
- if (tsdnsSrv) {
97
- return this.#setCache(inputAddr, [{ addr: tsdnsSrv, source: "TSDNS-SRV" }]);
116
+ const tsdnsSrv = await this.#resolveTSDNSSRV(domainList, host, port, signal);
117
+ if (tsdnsSrv.length > 0) {
118
+ return this.#setCache(
119
+ inputAddr,
120
+ tsdnsSrv.map((addr) => ({ addr, source: ResolveSource.TSDNSSRV })),
121
+ );
98
122
  }
99
123
 
100
124
  // TSDNS direct :41144
101
- const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, signal);
102
- if (tsdnsDirect) {
103
- return this.#setCache(inputAddr, [{ addr: tsdnsDirect, source: "TSDNS-Direct" }]);
125
+ const tsdnsDirect = await this.#resolveTSDNSDirect(domainList, host, port, signal);
126
+ if (tsdnsDirect.length > 0) {
127
+ return this.#setCache(
128
+ inputAddr,
129
+ tsdnsDirect.map((addr) => ({ addr, source: ResolveSource.TSDNSDirect })),
130
+ );
104
131
  }
105
132
 
106
133
  signal.throwIfAborted();
107
134
  // Plain DNS fallback
108
- const fallback: ResolvedAddr[] = [{ addr: joinHostPort(host, port), source: "Direct" }];
135
+ const fallback: ResolvedAddr[] = [
136
+ { addr: joinHostPort(host, port), source: ResolveSource.Direct },
137
+ ];
109
138
  return this.#setCache(inputAddr, fallback);
110
139
  }
111
140
 
@@ -130,7 +159,7 @@ export class Resolver {
130
159
  if (!srvs || srvs.length === 0) return null;
131
160
  return orderSrvRecords(srvs).map((srv) => ({
132
161
  addr: joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port)),
133
- source: "SRV",
162
+ source: ResolveSource.SRV,
134
163
  }));
135
164
  } catch {
136
165
  return null;
@@ -140,40 +169,77 @@ export class Resolver {
140
169
  async #resolveTSDNSSRV(
141
170
  domains: string[],
142
171
  queryHost: string,
172
+ port: string,
143
173
  signal?: AbortSignal,
144
- ): Promise<string | null> {
174
+ ): Promise<string[]> {
145
175
  for (const domain of domains) {
146
176
  try {
147
177
  const srvs = await abortable(resolveSrv(`_tsdns._tcp.${domain}`), signal);
148
178
  if (!srvs || srvs.length === 0) continue;
149
179
  for (const srv of orderSrvRecords(srvs)) {
150
180
  const tsdnsAddr = joinHostPort(srv.name.replace(/\.$/, ""), String(srv.port));
151
- const result = await queryTSDNS(tsdnsAddr, queryHost, signal);
152
- if (result) return result;
181
+ const result = await askTSDNS(tsdnsAddr, queryHost, port, signal);
182
+ if (result.length > 0) return result;
153
183
  }
154
184
  } catch {
155
185
  continue;
156
186
  }
157
187
  }
158
- return null;
188
+ return [];
159
189
  }
160
190
 
161
191
  async #resolveTSDNSDirect(
162
192
  domains: string[],
163
193
  queryHost: string,
194
+ port: string,
164
195
  signal?: AbortSignal,
165
- ): Promise<string | null> {
196
+ ): Promise<string[]> {
166
197
  // Asked at once rather than in turn, so the probes cost one timeout in
167
198
  // all; the most specific domain's answer wins.
168
199
  const answers = await Promise.all(
169
200
  domains.map((domain) =>
170
- queryTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, signal),
201
+ askTSDNS(joinHostPort(domain, String(TS_DNS_DEFAULT_PORT)), queryHost, port, signal),
171
202
  ),
172
203
  );
173
- return answers.find((answer) => answer !== null) ?? null;
204
+ return answers.find((answer) => answer.length > 0) ?? [];
174
205
  }
175
206
  }
176
207
 
208
+ /**
209
+ * Ask one TSDNS server; a server that fails to answer is one with no answer,
210
+ * so it never fails the resolution.
211
+ */
212
+ async function askTSDNS(
213
+ server: string,
214
+ host: string,
215
+ port: string,
216
+ signal?: AbortSignal,
217
+ ): Promise<string[]> {
218
+ try {
219
+ return normalizeTSDNSEntries(await lookupTSDNS(server, host, { signal }), port);
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Make each entry of a TSDNS answer a `host:port`, as TeamSpeak reads it: an
227
+ * entry without a port means 9987, and the port `$PORT` means `port`, the one
228
+ * the address being resolved had.
229
+ *
230
+ * @internal
231
+ */
232
+ export function normalizeTSDNSEntries(entries: readonly string[], port: string): string[] {
233
+ return entries.map((entry) => {
234
+ if (entry.endsWith(PORT_PLACEHOLDER)) {
235
+ const host = splitHostPort(entry.slice(0, -PORT_PLACEHOLDER.length)).host;
236
+ return joinHostPort(host, port);
237
+ }
238
+ const target = splitHostPort(entry, "9987");
239
+ return joinHostPort(target.host, target.port);
240
+ });
241
+ }
242
+
177
243
  // ---- Helpers ----------------------------------------------------------------
178
244
 
179
245
  /** The part of an SRV record that decides the order they are tried in. */
@@ -228,13 +294,27 @@ export function orderSrvRecords<T extends SrvRecord>(
228
294
  return ordered;
229
295
  }
230
296
 
297
+ function parseNicknameService(service: string | URL): URL {
298
+ let url: URL;
299
+ try {
300
+ url = new URL(service);
301
+ } catch {
302
+ throw new TypeError(`nicknameService is not a URL: ${String(service)}`);
303
+ }
304
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
305
+ throw new TypeError(`nicknameService is an http: or https: URL, not ${url.protocol}`);
306
+ }
307
+ return url;
308
+ }
309
+
231
310
  /** @internal Ask the nickname service for `nickname`'s address. */
232
311
  export async function resolveNickname(
233
312
  nickname: string,
313
+ service: URL,
234
314
  signal?: AbortSignal,
235
315
  ): Promise<string | null> {
236
316
  try {
237
- const url = new URL(NICKNAME_LOOKUP_URL);
317
+ const url = new URL(service);
238
318
  url.searchParams.set("name", nickname);
239
319
  const init: RequestInit = signal ? { signal } : {};
240
320
  const resp = await fetch(url.toString(), init);
@@ -267,54 +347,82 @@ async function readLimited(resp: Response, limit: number): Promise<string> {
267
347
  return Buffer.concat(chunks).toString("utf8");
268
348
  }
269
349
 
270
- /** @internal Ask the TSDNS server at `tsdnsAddr` for `queryHost`'s address. */
271
- export function queryTSDNS(
272
- tsdnsAddr: string,
273
- queryHost: string,
274
- signal?: AbortSignal,
275
- ): Promise<string | null> {
276
- const [host, portStr] = splitHostPortParts(tsdnsAddr);
277
- const port = parseInt(portStr, 10);
278
-
279
- return new Promise((resolve) => {
280
- if (signal?.aborted) return resolve(null);
281
- const finish = (answer: string | null): void => {
350
+ /**
351
+ * Ask a TSDNS server where `host` is. The answer is the server's targets in
352
+ * its order and as it sent them: an entry may have no port, which TeamSpeak
353
+ * takes as 9987, or the literal port `$PORT`, the port the user gave (9987 by
354
+ * default). {@link Resolver} applies both. A name the server has no record for
355
+ * gives an empty list.
356
+ *
357
+ * The answer ends at its first line break or when the server closes the
358
+ * connection, whichever comes first. It rejects when the connection fails,
359
+ * when no answer completes within three seconds or grows past 8 KiB, and with
360
+ * the signal's reason when it aborts.
361
+ *
362
+ * @param server - The TSDNS server, `host` or `host:port`; the port defaults to 41144.
363
+ * @param host - The name to look up; it is sent without a port.
364
+ */
365
+ export function lookupTSDNS(
366
+ server: string,
367
+ host: string,
368
+ options: { readonly signal?: AbortSignal | undefined } = {},
369
+ ): Promise<string[]> {
370
+ const { signal } = options;
371
+ const target = splitHostPort(server, String(TS_DNS_DEFAULT_PORT));
372
+ const port = parseInt(target.port, 10);
373
+
374
+ return new Promise((resolve, reject) => {
375
+ if (signal?.aborted) return reject(signal.reason);
376
+ const chunks: Buffer[] = [];
377
+ let size = 0;
378
+ let done = false;
379
+ const finish = (error: unknown, answer?: string[]): void => {
380
+ if (done) return;
381
+ done = true;
282
382
  clearTimeout(timeout);
283
383
  signal?.removeEventListener("abort", onAbort);
284
384
  socket.destroy();
285
- resolve(answer);
385
+ if (error !== undefined) reject(error);
386
+ else resolve(answer ?? []);
286
387
  };
287
- const onAbort = (): void => finish(null);
288
- const timeout = setTimeout(() => finish(null), TSDNS_TIMEOUT_MS);
388
+ const answerOf = (bytes: Buffer): string[] => {
389
+ const line = bytes.toString("utf8").trim();
390
+ return !line || line === "404" || line === "errors" ? [] : line.split(/\s+/);
391
+ };
392
+ const timedOut = (): void =>
393
+ finish(new TeamspeakError(`the TSDNS server ${server} did not answer in time`));
394
+ const onAbort = (): void => finish(signal?.reason);
395
+ const timeout = setTimeout(timedOut, TSDNS_TIMEOUT_MS);
289
396
 
290
- const socket = createConnection({ host, port, timeout: 2_000 }, () => {
291
- socket.write(`${queryHost}\n`);
397
+ const socket = createConnection({ host: target.host, port, timeout: 2_000 }, () => {
398
+ socket.write(`${host}\n`);
292
399
  });
293
400
 
294
- let buf = "";
295
401
  socket.on("data", (chunk: Buffer) => {
296
- buf += chunk.toString("latin1");
297
- const idx = buf.indexOf("\n");
402
+ chunks.push(chunk);
403
+ size += chunk.length;
404
+ const all = chunks.length === 1 ? chunks[0]! : Buffer.concat(chunks);
405
+ chunks.length = 0;
406
+ chunks.push(all);
407
+ const idx = all.indexOf(0x0a);
298
408
  if (idx >= 0 && idx < MAX_DISCOVERY_RESPONSE) {
299
- const line = Buffer.from(buf.slice(0, idx), "latin1").toString("utf8").trim();
300
- finish(!line || line === "404" || line === "errors" ? null : line);
301
- } else if (buf.length >= MAX_DISCOVERY_RESPONSE) {
409
+ finish(undefined, answerOf(all.subarray(0, idx)));
410
+ } else if (size >= MAX_DISCOVERY_RESPONSE) {
302
411
  // No answer is that long.
303
- finish(null);
412
+ finish(
413
+ new TeamspeakError(
414
+ `the TSDNS server ${server} sent ${MAX_DISCOVERY_RESPONSE} bytes without ending its answer`,
415
+ ),
416
+ );
304
417
  }
305
418
  });
306
-
307
- socket.on("error", () => finish(null));
308
- socket.on("timeout", () => finish(null));
419
+ socket.on("end", () => finish(undefined, answerOf(Buffer.concat(chunks))));
420
+ socket.on("error", (error) => finish(error));
421
+ socket.on("timeout", timedOut);
309
422
  signal?.addEventListener("abort", onAbort, { once: true });
310
423
  });
311
424
  }
312
425
 
313
- function splitHostPortParts(addr: string): [string, string] {
314
- const { host, port } = splitHostPort(addr);
315
- return [host, port];
316
- }
317
-
318
426
  function getDomainList(host: string): string[] {
319
427
  const parts = host.split(".");
320
428
  const list: string[] = [];
@@ -78,6 +78,7 @@ function parseLicenseBlock(data: Uint8Array): { block: LicenseBlock; consumed: n
78
78
  if (data.length < MIN_BLOCK_LEN) throw new Error("license too short");
79
79
  if (data[0] !== 0) throw new Error(`wrong key kind in license: ${data[0]}`);
80
80
 
81
+ // oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- read off the wire; parseBlockPayload refuses a value no member names
81
82
  const blockType = data[33] as LicenseBlockType;
82
83
 
83
84
  // Timestamps are seconds since Unix epoch offset by 0x50e22700
@@ -10,9 +10,13 @@
10
10
 
11
11
  // The client
12
12
  export { Client } from "./client.js";
13
- export { EventName, GroupWhisperType, GroupWhisperScope } from "./types.js";
13
+ export { EventName, GroupWhisperType, GroupWhisperScope, ResolveSource } from "./types.js";
14
14
 
15
- export type { ServerView, ViewChangedEvent, ViewReadyEvent, ViewStatus } from "./view.js";
15
+ export { Resolver, lookupTSDNS } from "./discovery/resolver.js";
16
+ export type { ResolverOptions } from "./discovery/resolver.js";
17
+
18
+ export { ViewStatus } from "./view.js";
19
+ export type { ServerView, ViewChangedEvent, ViewReadyEvent } from "./view.js";
16
20
 
17
21
  export type {
18
22
  EventMap,
@@ -47,11 +51,15 @@ export * from "./api/avatars.js";
47
51
  export * from "./api/files.js";
48
52
 
49
53
  // File transfer data
50
- export { dialFileTransfer, uploadFileData, downloadFileData } from "./transfer.js";
54
+ export {
55
+ dialFileTransfer,
56
+ uploadFileData,
57
+ downloadFileData,
58
+ FileTransferStage,
59
+ } from "./transfer.js";
51
60
  export type {
52
61
  FileTransferOptions,
53
62
  FileTransferTrace,
54
- FileTransferStage,
55
63
  FileTransferDialStartInfo,
56
64
  FileTransferDialDoneInfo,
57
65
  FileTransferProgressInfo,
@@ -99,4 +107,4 @@ export {
99
107
  uidAlgorithmForVersion,
100
108
  MAX_SECURITY_LEVEL,
101
109
  } from "./crypto/index.js";
102
- export type { UIDAlgorithm } from "./crypto/index.js";
110
+ export { UIDAlgorithm } from "./crypto/index.js";
@@ -44,7 +44,10 @@ export interface ResolveDoneInfo extends AttemptInfo {
44
44
  readonly addr: string;
45
45
  /** The `host:port` that will be dialled. */
46
46
  readonly target: string;
47
- /** What the resolver reports as the source of the answer dialled; may be empty. */
47
+ /**
48
+ * What the resolver reports as the source of the answer dialled: one of
49
+ * `ResolveSource` for the built-in resolver. May be empty.
50
+ */
48
51
  readonly source: string;
49
52
  /** Every answer the resolver gave, in order; empty when the address is used as given. */
50
53
  readonly candidates: readonly ResolvedAddr[];
@@ -44,8 +44,18 @@ export function failedToConnect(error: unknown): boolean {
44
44
  return typeof error === "object" && error !== null && failedBeforeConnect.has(error);
45
45
  }
46
46
 
47
+ /** The values of the table below, before freezing. */
48
+ const fileTransferStageValues = {
49
+ /** Reaching the transfer port, before the key is written. */
50
+ Dial: "dial",
51
+ /** After the connection opened, while the key or the file moved. */
52
+ Transfer: "transfer",
53
+ } as const;
47
54
  /** Where a failed transfer failed. */
48
- export type FileTransferStage = "dial" | "transfer";
55
+ export const FileTransferStage: typeof fileTransferStageValues =
56
+ /* @__PURE__ */ Object.freeze(fileTransferStageValues);
57
+ /** One of {@link FileTransferStage}. */
58
+ export type FileTransferStage = (typeof FileTransferStage)[keyof typeof FileTransferStage];
49
59
 
50
60
  /** The file transfer port is about to be dialled. */
51
61
  export interface FileTransferDialStartInfo {
@@ -10,6 +10,7 @@ import {
10
10
  PacketType,
11
11
  PacketFlags,
12
12
  packetType,
13
+ packetTypeOfByte,
13
14
  packetFlags,
14
15
  buildC2SHeader,
15
16
  parseS2CHeader,
@@ -563,7 +564,7 @@ export class PacketHandler {
563
564
  if (!this.#conn) return;
564
565
  if (data.length > TAG_SIZE + HEADER_SIZE - 1) {
565
566
  this.#traffic.recordSent(
566
- data[TAG_SIZE + HEADER_SIZE - 1]! & 0x0f,
567
+ packetTypeOfByte(data[TAG_SIZE + HEADER_SIZE - 1]!),
567
568
  data.length,
568
569
  performance.now(),
569
570
  );
@@ -597,7 +598,7 @@ export class PacketHandler {
597
598
  const parsed = parseS2CHeader(header);
598
599
  // Init1 belongs to the start of the handshake; once crypto init has
599
600
  // completed, another one can only restart a session that is already set up.
600
- const parsedType: PacketType = parsed.typeFlagged & 0x0f;
601
+ const parsedType = packetTypeOfByte(parsed.typeFlagged);
601
602
  if (parsedType === PacketType.Init1 && this.#crypt.cryptoInitComplete) return;
602
603
 
603
604
  const p: Packet = {
@@ -27,8 +27,17 @@ export interface Packet {
27
27
  receivedAt: number; // performance.now(), a monotonic clock
28
28
  }
29
29
 
30
+ /**
31
+ * The packet type in the low nibble of a type byte, as it came off the wire:
32
+ * the nibble can also hold 9 to 15, which name no type.
33
+ */
34
+ export function packetTypeOfByte(typeByte: number): PacketType {
35
+ // oxlint-disable-next-line typescript/no-unsafe-enum-assignment -- read off the wire, where the nibble can be any value
36
+ return typeByte & 0x0f;
37
+ }
38
+
30
39
  export function packetType(p: Packet): PacketType {
31
- return p.typeFlagged & 0x0f;
40
+ return packetTypeOfByte(p.typeFlagged);
32
41
  }
33
42
 
34
43
  export function packetFlags(p: Packet): number {
@@ -385,6 +385,32 @@ export type EventMiddleware = (next: (event: ServerEvent) => void) => (event: Se
385
385
 
386
386
  // ---- Resolved address (from discovery) --------------------------------------
387
387
 
388
+ /** The values of the table below, before freezing. */
389
+ const resolveSourceValues = {
390
+ /** A `_ts3._udp` SRV record of the host named the address. */
391
+ SRV: "SRV",
392
+ /**
393
+ * A TSDNS server, found through a `_tsdns._tcp` SRV record of the host's
394
+ * domain, named the address.
395
+ */
396
+ TSDNSSRV: "TSDNS-SRV",
397
+ /** A TSDNS server on the host's domain, at its default port, named the address. */
398
+ TSDNSDirect: "TSDNS-Direct",
399
+ /**
400
+ * The address as given: an IP address, a name nothing else answered for, or
401
+ * any name once resolution ran out of time.
402
+ */
403
+ Direct: "Direct",
404
+ } as const;
405
+ /** How the built-in resolver found an address, as `ResolvedAddr` records it. */
406
+ export const ResolveSource: typeof resolveSourceValues =
407
+ /* @__PURE__ */ Object.freeze(resolveSourceValues);
408
+ /**
409
+ * One of {@link ResolveSource}. May gain members in a minor release: a
410
+ * `switch` over it needs a default case.
411
+ */
412
+ export type ResolveSource = (typeof ResolveSource)[keyof typeof ResolveSource];
413
+
388
414
  /**
389
415
  * An address a server's name resolved to. Only `addr` is needed to connect;
390
416
  * the rest describes the answer.
@@ -392,7 +418,10 @@ export type EventMiddleware = (next: (event: ServerEvent) => void) => (event: Se
392
418
  export interface ResolvedAddr {
393
419
  /** The endpoint, as `host:port`. */
394
420
  readonly addr: string;
395
- /** How it was found, such as `SRV`, `TSDNS-SRV`, `TSDNS-Direct` or `Direct`. */
421
+ /**
422
+ * How it was found. The built-in resolver sets one of `ResolveSource`; a
423
+ * plain string, since an `AddrResolver` of your own may set any.
424
+ */
396
425
  readonly source?: string | undefined;
397
426
  /** When the answer stops being valid; unset for no stated lifetime. */
398
427
  readonly expiry?: Date | undefined;