@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.
Files changed (179) hide show
  1. package/dist/client/api/avatars.d.ts.map +1 -1
  2. package/dist/client/api/avatars.js +11 -2
  3. package/dist/client/api/avatars.js.map +1 -1
  4. package/dist/client/api/files.d.ts +36 -14
  5. package/dist/client/api/files.d.ts.map +1 -1
  6. package/dist/client/api/files.js +58 -9
  7. package/dist/client/api/files.js.map +1 -1
  8. package/dist/client/api/upload-checks.d.ts +36 -0
  9. package/dist/client/api/upload-checks.d.ts.map +1 -0
  10. package/dist/client/api/upload-checks.js +77 -0
  11. package/dist/client/api/upload-checks.js.map +1 -0
  12. package/dist/client/api/voice.d.ts +2 -1
  13. package/dist/client/api/voice.d.ts.map +1 -1
  14. package/dist/client/api/voice.js +6 -2
  15. package/dist/client/api/voice.js.map +1 -1
  16. package/dist/client/client.d.ts +13 -3
  17. package/dist/client/client.d.ts.map +1 -1
  18. package/dist/client/client.js +151 -49
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/client/crypto/crypt.d.ts +6 -0
  21. package/dist/client/crypto/crypt.d.ts.map +1 -1
  22. package/dist/client/crypto/crypt.js +13 -7
  23. package/dist/client/crypto/crypt.js.map +1 -1
  24. package/dist/client/crypto/eax.d.ts +4 -0
  25. package/dist/client/crypto/eax.d.ts.map +1 -1
  26. package/dist/client/crypto/eax.js +27 -21
  27. package/dist/client/crypto/eax.js.map +1 -1
  28. package/dist/client/errors.d.ts +13 -0
  29. package/dist/client/errors.d.ts.map +1 -1
  30. package/dist/client/errors.js +23 -0
  31. package/dist/client/errors.js.map +1 -1
  32. package/dist/client/handshake/crypt-handshake.d.ts +2 -1
  33. package/dist/client/handshake/crypt-handshake.d.ts.map +1 -1
  34. package/dist/client/handshake/crypt-handshake.js +4 -2
  35. package/dist/client/handshake/crypt-handshake.js.map +1 -1
  36. package/dist/client/index.d.ts +1 -1
  37. package/dist/client/index.d.ts.map +1 -1
  38. package/dist/client/index.js +1 -1
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/throttle.d.ts +11 -0
  41. package/dist/client/throttle.d.ts.map +1 -1
  42. package/dist/client/throttle.js +38 -3
  43. package/dist/client/throttle.js.map +1 -1
  44. package/dist/client/transfer.d.ts +37 -4
  45. package/dist/client/transfer.d.ts.map +1 -1
  46. package/dist/client/transfer.js +155 -15
  47. package/dist/client/transfer.js.map +1 -1
  48. package/dist/client/transport/handler.d.ts.map +1 -1
  49. package/dist/client/transport/handler.js +47 -43
  50. package/dist/client/transport/handler.js.map +1 -1
  51. package/dist/core/api/clients.d.ts +15 -15
  52. package/dist/core/api/clients.d.ts.map +1 -1
  53. package/dist/core/api/clients.js.map +1 -1
  54. package/dist/core/api/command.d.ts +8 -6
  55. package/dist/core/api/command.d.ts.map +1 -1
  56. package/dist/core/api/command.js +1 -1
  57. package/dist/core/api/command.js.map +1 -1
  58. package/dist/core/api/files.d.ts +23 -6
  59. package/dist/core/api/files.d.ts.map +1 -1
  60. package/dist/core/api/files.js +12 -9
  61. package/dist/core/api/files.js.map +1 -1
  62. package/dist/core/api/groups.d.ts +17 -2
  63. package/dist/core/api/groups.d.ts.map +1 -1
  64. package/dist/core/api/groups.js +13 -11
  65. package/dist/core/api/groups.js.map +1 -1
  66. package/dist/core/api/parse.d.ts +5 -5
  67. package/dist/core/api/parse.d.ts.map +1 -1
  68. package/dist/core/api/parse.js +16 -15
  69. package/dist/core/api/parse.js.map +1 -1
  70. package/dist/core/command/request.d.ts +6 -0
  71. package/dist/core/command/request.d.ts.map +1 -1
  72. package/dist/core/command/request.js +12 -0
  73. package/dist/core/command/request.js.map +1 -1
  74. package/dist/core/errors.d.ts +46 -3
  75. package/dist/core/errors.d.ts.map +1 -1
  76. package/dist/core/errors.js +73 -20
  77. package/dist/core/errors.js.map +1 -1
  78. package/dist/core/events.d.ts +24 -8
  79. package/dist/core/events.d.ts.map +1 -1
  80. package/dist/core/internal.d.ts +1 -1
  81. package/dist/core/internal.d.ts.map +1 -1
  82. package/dist/core/internal.js +1 -1
  83. package/dist/core/internal.js.map +1 -1
  84. package/dist/core/notifications.d.ts +1 -1
  85. package/dist/core/notifications.js +9 -8
  86. package/dist/core/notifications.js.map +1 -1
  87. package/dist/core/reconnect.d.ts +16 -5
  88. package/dist/core/reconnect.d.ts.map +1 -1
  89. package/dist/core/reconnect.js +32 -4
  90. package/dist/core/reconnect.js.map +1 -1
  91. package/dist/core/types.d.ts +15 -16
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/query/client.d.ts +4 -0
  95. package/dist/query/client.d.ts.map +1 -1
  96. package/dist/query/client.js +145 -23
  97. package/dist/query/client.js.map +1 -1
  98. package/dist/query/errors.d.ts +20 -0
  99. package/dist/query/errors.d.ts.map +1 -1
  100. package/dist/query/errors.js +31 -0
  101. package/dist/query/errors.js.map +1 -1
  102. package/dist/query/index.d.ts +1 -1
  103. package/dist/query/index.d.ts.map +1 -1
  104. package/dist/query/index.js +1 -1
  105. package/dist/query/index.js.map +1 -1
  106. package/dist/query/lines.d.ts +11 -0
  107. package/dist/query/lines.d.ts.map +1 -1
  108. package/dist/query/lines.js +36 -6
  109. package/dist/query/lines.js.map +1 -1
  110. package/dist/query/options.d.ts +30 -0
  111. package/dist/query/options.d.ts.map +1 -1
  112. package/dist/query/options.js +7 -0
  113. package/dist/query/options.js.map +1 -1
  114. package/dist/query/reconnect.d.ts +5 -0
  115. package/dist/query/reconnect.d.ts.map +1 -1
  116. package/dist/query/reconnect.js +26 -7
  117. package/dist/query/reconnect.js.map +1 -1
  118. package/dist/query/ssh.js +9 -4
  119. package/dist/query/ssh.js.map +1 -1
  120. package/dist/query/stream.d.ts +2 -0
  121. package/dist/query/stream.d.ts.map +1 -1
  122. package/dist/query/stream.js +19 -1
  123. package/dist/query/stream.js.map +1 -1
  124. package/dist/query/transport.d.ts +12 -2
  125. package/dist/query/transport.d.ts.map +1 -1
  126. package/dist/query/web.d.ts +3 -1
  127. package/dist/query/web.d.ts.map +1 -1
  128. package/dist/query/web.js +82 -18
  129. package/dist/query/web.js.map +1 -1
  130. package/dist/stream/published.d.ts.map +1 -1
  131. package/dist/stream/published.js +61 -25
  132. package/dist/stream/published.js.map +1 -1
  133. package/dist/stream/streams.d.ts.map +1 -1
  134. package/dist/stream/streams.js +51 -10
  135. package/dist/stream/streams.js.map +1 -1
  136. package/dist/stream/wire.d.ts +2 -2
  137. package/dist/stream/wire.d.ts.map +1 -1
  138. package/dist/stream/wire.js +27 -7
  139. package/dist/stream/wire.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/client/api/avatars.ts +15 -2
  142. package/src/client/api/files.ts +83 -23
  143. package/src/client/api/upload-checks.ts +96 -0
  144. package/src/client/api/voice.ts +5 -2
  145. package/src/client/client.ts +154 -42
  146. package/src/client/crypto/crypt.ts +15 -7
  147. package/src/client/crypto/eax.ts +27 -22
  148. package/src/client/errors.ts +25 -0
  149. package/src/client/handshake/crypt-handshake.ts +6 -3
  150. package/src/client/index.ts +1 -0
  151. package/src/client/throttle.ts +41 -3
  152. package/src/client/transfer.ts +172 -28
  153. package/src/client/transport/handler.ts +54 -46
  154. package/src/core/api/clients.ts +24 -21
  155. package/src/core/api/command.ts +9 -7
  156. package/src/core/api/files.ts +30 -16
  157. package/src/core/api/groups.ts +32 -13
  158. package/src/core/api/parse.ts +16 -15
  159. package/src/core/command/request.ts +18 -0
  160. package/src/core/errors.ts +74 -20
  161. package/src/core/events.ts +24 -8
  162. package/src/core/internal.ts +7 -1
  163. package/src/core/notifications.ts +9 -8
  164. package/src/core/reconnect.ts +60 -8
  165. package/src/core/types.ts +15 -16
  166. package/src/query/client.ts +144 -20
  167. package/src/query/errors.ts +34 -0
  168. package/src/query/index.ts +1 -0
  169. package/src/query/lines.ts +38 -6
  170. package/src/query/options.ts +36 -0
  171. package/src/query/reconnect.ts +27 -7
  172. package/src/query/ssh.ts +11 -4
  173. package/src/query/stream.ts +17 -1
  174. package/src/query/testing/live.ts +4 -4
  175. package/src/query/transport.ts +10 -2
  176. package/src/query/web.ts +76 -21
  177. package/src/stream/published.ts +70 -23
  178. package/src/stream/streams.ts +47 -13
  179. package/src/stream/wire.ts +30 -11
package/src/query/ssh.ts CHANGED
@@ -591,10 +591,17 @@ function loginOf(options: SshChoice): { username: string; password: string | und
591
591
  return { username: "guest", password: undefined };
592
592
  }
593
593
  const hint = "; to connect without a login, as a guest, set guest to true";
594
- return {
595
- username: nonEmptyString(options.username, "username", hint),
596
- password: nonEmptyString(options.password, "password", hint),
597
- };
594
+ const username = nonEmptyString(options.username, "username", hint);
595
+ // Measured on TeamSpeak 6 6.0.0-beta13.1: the user `guest` logs in as a guest
596
+ // whatever password it is given, the administrator's included, so a password
597
+ // sent with it is silently ignored and the access is not the account's. The
598
+ // server matches the name exactly: `GUEST` is refused.
599
+ if (username === "guest") {
600
+ throw new TypeError(
601
+ '"guest" is the server\'s guest user, which takes no password; to connect as a guest, set guest to true',
602
+ );
603
+ }
604
+ return { username, password: nonEmptyString(options.password, "password", hint) };
598
605
  }
599
606
 
600
607
  function nonEmptyString(value: unknown, name: string, hint = ""): string {
@@ -10,6 +10,8 @@ export interface QueryStream {
10
10
  write(data: string, callback: (error?: Error | null) => void): unknown;
11
11
  /** Close the stream. */
12
12
  end(): unknown;
13
+ /** Drop the stream without waiting for the peer; used for a peer that must not be read on. */
14
+ destroy?(): unknown;
13
15
  /** Listen for each chunk received. */
14
16
  on(event: "data", listener: (chunk: Uint8Array) => void): unknown;
15
17
  /** Listen for a failure, which ends the connection. */
@@ -129,8 +131,22 @@ export class StreamTransport implements QueryTransport {
129
131
  }
130
132
 
131
133
  #handleData(chunk: Uint8Array): void {
134
+ if (this.#ended) return;
132
135
  this.#bytesReceived += chunk.byteLength;
133
- for (const line of this.#reader.push(chunk)) {
136
+ let lines: string[];
137
+ try {
138
+ lines = this.#reader.push(chunk);
139
+ } catch (error) {
140
+ // A server that is not speaking ServerQuery: nothing more is read, and
141
+ // the connection ends with the reason.
142
+ this.#error = error as Error;
143
+ this.onLine = null;
144
+ if (this.#stream.destroy === undefined) this.#stream.end();
145
+ else this.#stream.destroy();
146
+ this.#handleClose();
147
+ return;
148
+ }
149
+ for (const line of lines) {
134
150
  if (this.#banner > 0) {
135
151
  this.#banner--;
136
152
  continue;
@@ -178,9 +178,9 @@ export class LiveSession {
178
178
  return this.#knownHosts;
179
179
  }
180
180
 
181
- /** Probe the SSH host key once, then connect one client per transport. */
182
- async open(): Promise<void> {
183
- if (KINDS.includes("ssh")) {
181
+ /** Probe the SSH host key once, then connect one client per transport, or per `kinds` when given. */
182
+ async open(kinds: readonly Kind[] = KINDS): Promise<void> {
183
+ if (kinds.includes("ssh")) {
184
184
  if (SSH_KNOWN_HOSTS !== "") {
185
185
  this.#knownHosts = SSH_KNOWN_HOSTS;
186
186
  } else {
@@ -192,7 +192,7 @@ export class LiveSession {
192
192
  );
193
193
  }
194
194
  }
195
- for (const kind of KINDS) {
195
+ for (const kind of kinds) {
196
196
  const client = new QueryClient(optionsFor(kind, this.#knownHosts));
197
197
  await connectWithRetries(client);
198
198
  this.clients.set(kind, client);
@@ -53,8 +53,16 @@ export interface QueryTransport {
53
53
  * run.
54
54
  */
55
55
  readonly supportsNotifications: boolean;
56
- /** Send one command line; the transport adds the line terminator. */
57
- send(line: string): Promise<void>;
56
+ /**
57
+ * Send one command line; the transport adds the line terminator.
58
+ *
59
+ * `signal` aborts when the command has stopped waiting for its answer, by
60
+ * timeout, cancel or close. A transport whose `send` carries the whole round
61
+ * trip, as WebQuery's does, ends the request and rejects; a stream has
62
+ * nothing to take back once the line is written, and ignores it. A transport
63
+ * that ignores it must not deliver the answer to the next command either.
64
+ */
65
+ send(line: string, options?: { readonly signal?: AbortSignal }): Promise<void>;
58
66
  /**
59
67
  * Called with each line the server sends, without its terminator, in the
60
68
  * order received.
package/src/query/web.ts CHANGED
@@ -127,6 +127,7 @@ export class WebQueryTransport implements QueryTransport {
127
127
  #prefix: string;
128
128
  #fetch: typeof fetch;
129
129
  #closed = false;
130
+ #inFlight = new Set<AbortController>();
130
131
  #openedAt = new Date();
131
132
  #bytesSent = 0;
132
133
  #bytesReceived = 0;
@@ -155,7 +156,7 @@ export class WebQueryTransport implements QueryTransport {
155
156
  this.#fetch = options.fetch ?? nodeFetch;
156
157
  }
157
158
 
158
- async send(line: string): Promise<void> {
159
+ async send(line: string, options?: { readonly signal?: AbortSignal }): Promise<void> {
159
160
  if (this.#closed) throw new Error("the query connection is closed");
160
161
  const rows = splitCommandRows(line)
161
162
  .map((row) => parseCommand(row))
@@ -169,23 +170,52 @@ export class WebQueryTransport implements QueryTransport {
169
170
 
170
171
  const params = rows.map((row) => ({ ...row.params }));
171
172
  const body = JSON.stringify(params.length === 1 ? params[0] : params);
172
- const response = await this.#fetch(`${this.#base}${this.#prefix}/${name}`, {
173
- method: "POST",
174
- headers: {
175
- ...(this.#apiKey === undefined ? {} : { "x-api-key": this.#apiKey }),
176
- "content-type": "application/json",
177
- },
178
- body,
179
- });
180
- this.#bytesSent += Buffer.byteLength(body);
181
-
173
+ // One controller per request: the command's own signal and `close()` both
174
+ // end it, so a request nobody waits for any more is not left running.
175
+ const abort = new AbortController();
176
+ this.#inFlight.add(abort);
177
+ const onAbort = (): void => abort.abort(options?.signal?.reason);
178
+ if (options?.signal?.aborted === true) onAbort();
179
+ else options?.signal?.addEventListener("abort", onAbort, { once: true });
180
+ let response: Response;
182
181
  let answer: WebAnswer | undefined;
183
182
  try {
184
- const text = await response.text();
185
- this.#bytesReceived += Buffer.byteLength(text);
186
- answer = JSON.parse(text) as WebAnswer;
187
- } catch {
188
- // Not JSON: nothing the server said about the command.
183
+ response = await this.#fetch(`${this.#base}${this.#prefix}/${name}`, {
184
+ method: "POST",
185
+ headers: {
186
+ ...(this.#apiKey === undefined ? {} : { "x-api-key": this.#apiKey }),
187
+ "content-type": "application/json",
188
+ },
189
+ body,
190
+ // The api key is a header, and a followed redirect would carry it to
191
+ // whatever host the answer names: the 3xx is the answer.
192
+ redirect: "manual",
193
+ signal: abort.signal,
194
+ });
195
+ this.#bytesSent += Buffer.byteLength(body);
196
+
197
+ let text: string | undefined;
198
+ try {
199
+ text = await response.text();
200
+ } catch (error) {
201
+ // An abort is the command's end, not an answer that was not JSON.
202
+ if (abort.signal.aborted) throw error;
203
+ }
204
+ if (text !== undefined) {
205
+ this.#bytesReceived += Buffer.byteLength(text);
206
+ try {
207
+ answer = JSON.parse(text) as WebAnswer;
208
+ } catch {
209
+ // Not JSON: nothing the server said about the command.
210
+ }
211
+ }
212
+ // A fetch that ignores its signal still answers late. The command that
213
+ // asked has given up, and the client may be waiting on the next one: its
214
+ // lines must not be this answer's.
215
+ if (abort.signal.aborted) throw abortReason(abort.signal);
216
+ } finally {
217
+ options?.signal?.removeEventListener("abort", onAbort);
218
+ this.#inFlight.delete(abort);
189
219
  }
190
220
  const code = answer?.status?.code;
191
221
  if (typeof code !== "number") throw new WebQueryHttpError(response.status, response.statusText);
@@ -228,6 +258,9 @@ export class WebQueryTransport implements QueryTransport {
228
258
  close(): Promise<void> {
229
259
  if (this.#closed) return Promise.resolve();
230
260
  this.#closed = true;
261
+ // Nothing is waiting for these any more, and none may deliver a line.
262
+ for (const request of this.#inFlight)
263
+ request.abort(new Error("the query connection is closed"));
231
264
  this.onClose?.(undefined);
232
265
  return Promise.resolve();
233
266
  }
@@ -251,11 +284,29 @@ export class WebQueryTransport implements QueryTransport {
251
284
  }
252
285
  }
253
286
 
254
- /** One answer row as a line of `key=value` fields; a field that is not text is written as text. */
287
+ /** Why an aborted request ended: the signal's own reason, as an error. */
288
+ function abortReason(signal: AbortSignal): Error {
289
+ const reason: unknown = signal.reason;
290
+ return reason instanceof Error ? reason : new Error("the WebQuery request was aborted");
291
+ }
292
+
293
+ /**
294
+ * One JSON value as the text a command line carries: a string as it is, a
295
+ * number as written, a boolean as `1` or `0`, and anything else (a null, a
296
+ * nested object) as empty, which is a field the row did not carry. WebQuery
297
+ * sends every value as a string, measured on TeamSpeak 3 and 6; this is for
298
+ * a server that does not.
299
+ */
300
+ function valueText(value: unknown): string {
301
+ if (typeof value === "string") return value;
302
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
303
+ if (typeof value === "boolean") return value ? "1" : "0";
304
+ return "";
305
+ }
306
+
307
+ /** One answer row as a line of `key=value` fields. */
255
308
  function rowLine(row: object): string {
256
- const fields = Object.entries(row).map(
257
- ([key, value]) => [key, typeof value === "string" ? value : String(value)] as const,
258
- );
309
+ const fields = Object.entries(row).map(([key, value]) => [key, valueText(value)] as const);
259
310
  // buildCommandOrdered writes a name first; a row has none.
260
311
  return buildCommandOrdered("", fields).slice(1);
261
312
  }
@@ -270,7 +321,11 @@ const nodeFetch = ((url: string, init: RequestInit) =>
270
321
  const request = new URL(url).protocol === "https:" ? httpsRequest : httpRequest;
271
322
  const req = request(
272
323
  url,
273
- { method: init.method, headers: init.headers as Record<string, string> },
324
+ {
325
+ method: init.method,
326
+ headers: init.headers as Record<string, string>,
327
+ ...(init.signal ? { signal: init.signal } : {}),
328
+ },
274
329
  (res) => {
275
330
  const chunks: Buffer[] = [];
276
331
  res.on("data", (chunk: Buffer) => chunks.push(chunk));
@@ -43,6 +43,22 @@ export interface PublishHost {
43
43
  stopped(streamId: string, reason: number): void;
44
44
  }
45
45
 
46
+ /** How many identity lookups of one stream run at once. */
47
+ const MAX_LOOKUPS = 8;
48
+
49
+ /**
50
+ * A request waiting to be announced. The slot itself is the request's token: a
51
+ * lookup answers for the slot it was made for, not for whatever request the
52
+ * same client id has by then.
53
+ */
54
+ interface Slot {
55
+ readonly clientId: ClientID;
56
+ readonly message: string;
57
+ identity: ViewerIdentity | undefined;
58
+ /** Whether the lookup is out. */
59
+ looking: boolean;
60
+ }
61
+
46
62
  class JoinRequestImpl implements JoinRequest {
47
63
  readonly streamId: string;
48
64
  readonly viewer: ViewerIdentity;
@@ -133,9 +149,9 @@ export class PublishedImpl implements PublishedStream {
133
149
  #peers = new Map<ClientID, ViewerPeerImpl>();
134
150
  #pending = new Map<ClientID, JoinRequestImpl>();
135
151
  /** Viewers asked about, until the answer comes back, so that a repeat is no new request. */
136
- #resolving = new Set<ClientID>();
137
- /** Requests are announced in the order they came, whatever the lookups take. */
138
- #announcing: Promise<void> = Promise.resolve();
152
+ #resolving = new Map<ClientID, Slot>();
153
+ /** Requests are announced in the order they came, whatever the lookups take; the lookups run side by side. */
154
+ #slots: Slot[] = [];
139
155
  #lookups = 0;
140
156
  /** Viewers whose peer is over: what they still send is not worth a report. */
141
157
  #closedViewers = new Set<ClientID>();
@@ -210,31 +226,60 @@ export class PublishedImpl implements PublishedStream {
210
226
  // Asking again while pending, being looked up or let in is no new request.
211
227
  if (this.#ended || this.#peers.has(clientId) || this.#resolving.has(clientId)) return;
212
228
  if (this.#pending.get(clientId)?.state === "pending") return;
213
- this.#resolving.add(clientId);
214
- const identity = this.host.identity(clientId);
215
- const announce = (viewer: ViewerIdentity): void => {
216
- // Withdrawn, or the stream over, while the lookup was out.
217
- if (!this.#resolving.delete(clientId) || this.#ended) return;
218
- if (this.host.isDenied(viewer)) return;
219
- const request = new JoinRequestImpl(this, viewer, message);
220
- this.#pending.set(clientId, request);
221
- this.#emitter.emit("request", request);
222
- };
223
- // Nothing to wait for, and nothing ahead of it to wait for: at once.
224
- if (!(identity instanceof Promise) && this.#lookups === 0) {
225
- announce(identity);
226
- return;
229
+ const slot: Slot = { clientId, message, identity: undefined, looking: false };
230
+ this.#resolving.set(clientId, slot);
231
+ this.#slots.push(slot);
232
+ this.#advance();
233
+ }
234
+
235
+ /** Start the lookups that may start, then announce the requests whose turn it is. */
236
+ #advance(): void {
237
+ for (const slot of this.#slots) {
238
+ if (slot.identity !== undefined || slot.looking) continue;
239
+ if (this.#resolving.get(slot.clientId) !== slot) continue;
240
+ if (this.#lookups >= MAX_LOOKUPS) break;
241
+ const identity = this.host.identity(slot.clientId);
242
+ if (!(identity instanceof Promise)) {
243
+ slot.identity = identity;
244
+ continue;
245
+ }
246
+ slot.looking = true;
247
+ this.#lookups++;
248
+ void identity.then((viewer) => {
249
+ slot.looking = false;
250
+ this.#lookups--;
251
+ slot.identity = viewer;
252
+ this.#advance();
253
+ });
254
+ }
255
+ // In the order they came: a request waits for the ones ahead of it.
256
+ while (this.#slots.length > 0) {
257
+ const head = this.#slots[0]!;
258
+ const current = this.#resolving.get(head.clientId) === head && !this.#ended;
259
+ if (current && head.identity === undefined) break;
260
+ this.#slots.shift();
261
+ if (current) this.#announce(head);
227
262
  }
228
- this.#lookups++;
229
- this.#announcing = this.#announcing
230
- .then(() => identity)
231
- .then(announce)
232
- .finally(() => this.#lookups--);
263
+ }
264
+
265
+ #announce(slot: Slot): void {
266
+ this.#resolving.delete(slot.clientId);
267
+ const viewer = slot.identity!;
268
+ if (this.host.isDenied(viewer)) return;
269
+ const request = new JoinRequestImpl(this, viewer, slot.message);
270
+ this.#pending.set(slot.clientId, request);
271
+ this.#emitter.emit("request", request);
233
272
  }
234
273
 
235
274
  /** The client left view: a request of its that nobody answered is forgotten, as the clid may be reused. */
236
275
  dropPending(clientId: ClientID): void {
237
- this.#resolving.delete(clientId);
276
+ const slot = this.#resolving.get(clientId);
277
+ if (slot !== undefined) {
278
+ this.#resolving.delete(clientId);
279
+ // One whose lookup is out stays until it answers, which counts it.
280
+ if (!slot.looking) this.#slots.splice(this.#slots.indexOf(slot), 1);
281
+ this.#advance();
282
+ }
238
283
  this.#pending.get(clientId)?.close("withdrawn");
239
284
  this.#pending.delete(clientId);
240
285
  }
@@ -261,6 +306,8 @@ export class PublishedImpl implements PublishedStream {
261
306
  finish(ended: StreamEnded): void {
262
307
  if (this.#ended) return;
263
308
  this.#ended = true;
309
+ this.#slots.length = 0;
310
+ this.#resolving.clear();
264
311
  for (const request of this.#pending.values()) request.close("ended");
265
312
  this.#pending.clear();
266
313
  const cause =
@@ -42,10 +42,10 @@ import {
42
42
  assertFits,
43
43
  clientOf,
44
44
  infoRequest,
45
- intOf,
46
45
  joinRequest,
47
46
  sameInfo,
48
47
  setupRequest,
48
+ reasonOf,
49
49
  signalRequest,
50
50
  sourceToWire,
51
51
  STREAM_P2P,
@@ -78,6 +78,8 @@ const GONE_GRACE_MS = 2000;
78
78
  const ANNOUNCE_WINDOW_MS = 5000;
79
79
  /** How many streams stopped for a publish that gave up are remembered, to ignore their late rows. */
80
80
  const MAX_ORPHANS = 16;
81
+ /** How many streams the server or this client stopped are remembered, so that a late answer does not bring one back. */
82
+ const MAX_STOPPED = 256;
81
83
 
82
84
  function signalOption(signal: AbortSignal | undefined): CommandOptions {
83
85
  return signal === undefined ? {} : { signal };
@@ -152,6 +154,11 @@ export class Streams {
152
154
  * not bring it back.
153
155
  */
154
156
  readonly #orphans = new Set<string>();
157
+ /**
158
+ * Streams that were stopped, newest last. A `setupstream` answered after
159
+ * its stream's `notifystreamstopped` must not adopt a stream that is over.
160
+ */
161
+ readonly #stopped = new Set<string>();
155
162
  #disposed = false;
156
163
 
157
164
  /**
@@ -344,6 +351,11 @@ export class Streams {
344
351
  this.#orphan(info);
345
352
  return;
346
353
  }
354
+ if (this.#stopped.has(info.id)) {
355
+ // Stopped before its own answer came: there is nothing to adopt.
356
+ attempt.outcome.reject(new StreamEndedError(info.id));
357
+ return;
358
+ }
347
359
  attempt.outcome.resolve(this.#adopt(info));
348
360
  } finally {
349
361
  this.#publishing.delete(attempt);
@@ -409,12 +421,16 @@ export class Streams {
409
421
  };
410
422
  this.#watching.set(id, watch);
411
423
  this.#closedViews.delete(id);
424
+ // Whether the join request left the flood throttle: only then may it be answered.
425
+ let sent = false;
412
426
  try {
413
- await this.#send(
414
- joinRequest(id, streamer, options.message ?? "", false),
415
- id,
416
- signalOption(options.signal),
417
- );
427
+ await this.#send(joinRequest(id, streamer, options.message ?? "", false), id, {
428
+ ...signalOption(options.signal),
429
+ onSent: () => {
430
+ sent = true;
431
+ },
432
+ });
433
+ sent = true;
418
434
  return await within(
419
435
  watch.settled.promise,
420
436
  "joinstreamrequest",
@@ -422,15 +438,26 @@ export class Streams {
422
438
  options.signal,
423
439
  );
424
440
  } catch (error) {
425
- if (this.#watching.get(id) === watch && watch.view === undefined) {
426
- this.#watching.delete(id);
427
- // Asked and not answered, or answered without a usable offer: withdraw.
441
+ if (this.#watching.get(id) !== watch || watch.view !== undefined) {
442
+ // Settled while the request was being sent, or while this gave up: the
443
+ // view, or the refusal, is the outcome. The stream's end can be heard
444
+ // while the request is sent; the server's own refusal then says more.
445
+ if (watch.view !== undefined) return watch.view;
446
+ if (error instanceof StreamEndedError) throw error;
447
+ return await watch.settled.promise;
448
+ }
449
+ this.#watching.delete(id);
450
+ if (error instanceof StreamEndedError) {
451
+ // 5635 on a join request: the stream is gone, whatever this client heard.
452
+ this.#end(id, 0, "gone");
453
+ } else {
454
+ // Asked and not answered, or answered without a usable offer: withdraw,
455
+ // if the request may have reached the server.
428
456
  const answered =
429
457
  error instanceof StreamJoinDeniedError ||
430
- error instanceof StreamEndedError ||
431
458
  error instanceof NotConnectedError ||
432
459
  error instanceof ServerError;
433
- if (!answered) void this.#leave(id, streamer).catch(() => {});
460
+ if (sent && !answered) this.#bestEffort(this.#leave(id, streamer));
434
461
  }
435
462
  throw error;
436
463
  }
@@ -618,7 +645,7 @@ export class Streams {
618
645
  case "notifystreamstopped": {
619
646
  const id = params["id"] ?? "";
620
647
  if (id !== "") {
621
- this.#end(id, intOf(params, "reason", 0), "server");
648
+ this.#end(id, reasonOf(params), "server");
622
649
  this.#orphans.delete(id);
623
650
  }
624
651
  break;
@@ -664,7 +691,7 @@ export class Streams {
664
691
  watch.settled.reject(
665
692
  new TeamspeakError("the publisher accepted the request without an offer"),
666
693
  );
667
- void this.#leave(id, watch.streamer).catch(() => {});
694
+ this.#bestEffort(this.#leave(id, watch.streamer));
668
695
  return;
669
696
  }
670
697
  const view = new StreamViewImpl(id, watch.streamer, offer, {
@@ -731,6 +758,12 @@ export class Streams {
731
758
 
732
759
  /** A stream is over, for whatever hangs on it, and is forgotten. */
733
760
  #end(id: string, reason: number, cause: "stopped" | "server" | "gone"): void {
761
+ if (cause !== "gone") {
762
+ this.#stopped.delete(id);
763
+ this.#stopped.add(id);
764
+ if (this.#stopped.size > MAX_STOPPED)
765
+ this.#stopped.delete(this.#stopped.values().next().value!);
766
+ }
734
767
  const info = this.#infos.get(id);
735
768
  this.#infos.delete(id);
736
769
  if (
@@ -764,6 +797,7 @@ export class Streams {
764
797
  }
765
798
  this.#publishing.clear();
766
799
  this.#orphans.clear();
800
+ this.#stopped.clear();
767
801
 
768
802
  const ended: StreamEnded = { cause, reason: 0 };
769
803
  const published = [...this.#published.values()];
@@ -203,6 +203,17 @@ export type Signal =
203
203
  | { readonly kind: "candidate"; readonly candidate: IceCandidate }
204
204
  | { readonly kind: "raw"; readonly signal: RawSignal };
205
205
 
206
+ /** Whether `value` is an integer an unsigned 16-bit field holds. */
207
+ function isUint16(value: unknown): value is number {
208
+ return (
209
+ typeof value === "number" &&
210
+ Number.isInteger(value) &&
211
+ value >= 0 &&
212
+ value <= 0xffff &&
213
+ !Object.is(value, -0)
214
+ );
215
+ }
216
+
206
217
  function isRecord(value: unknown): value is Record<string, unknown> {
207
218
  return typeof value === "object" && value !== null && !Array.isArray(value);
208
219
  }
@@ -253,7 +264,7 @@ export function parseSignal(json: string): Signal {
253
264
  candidate: {
254
265
  candidate: args["sdp"],
255
266
  sdpMid: typeof mid === "string" ? mid : null,
256
- sdpMLineIndex: typeof line === "number" && Number.isInteger(line) ? line : null,
267
+ sdpMLineIndex: isUint16(line) ? line : null,
257
268
  },
258
269
  };
259
270
  }
@@ -268,6 +279,17 @@ function int(raw: string | undefined): number | undefined {
268
279
  return raw !== undefined && /^-?\d{1,15}$/.test(raw) ? Number(raw) : undefined;
269
280
  }
270
281
 
282
+ /**
283
+ * An unsigned 32-bit number, which is what a source, an accessibility, a mode
284
+ * and a stop reason are; undefined when it is absent, none, or out of range.
285
+ * `maxDigits` bounds the text, as for the signed numbers.
286
+ */
287
+ function uint32(raw: string | undefined, maxDigits = 15): number | undefined {
288
+ if (raw === undefined || !/^\d+$/.test(raw) || raw.length > maxDigits) return undefined;
289
+ const n = Number(raw);
290
+ return n <= 0xffff_ffff ? n : undefined;
291
+ }
292
+
271
293
  /**
272
294
  * The stream a `notifystreamstarted` or `notifystreaminfo` describes, merged
273
295
  * over what was known of it. Undefined for the empty `notifystreaminfo` the
@@ -283,9 +305,9 @@ export function streamInfoFrom(
283
305
  const id = params["id"];
284
306
  if (id === undefined || id === "") return undefined;
285
307
 
286
- const type = int(params["type"]);
287
- const access = int(params["accessibility"] ?? params["access"]);
288
- const mode = int(params["mode"]);
308
+ const type = uint32(params["type"]);
309
+ const access = uint32(params["accessibility"] ?? params["access"]);
310
+ const mode = uint32(params["mode"]);
289
311
  const bitrate = int(params["bitrate"]);
290
312
  const limit = int(params["viewer_limit"]);
291
313
  const viewers = int(params["viewer"]);
@@ -326,13 +348,10 @@ export function clientOf(params: Readonly<Record<string, string>>, key: string):
326
348
  return asClientID(parseUint16(params[key] ?? ""));
327
349
  }
328
350
 
329
- /** The integer a notification carries under `key`, or `fallback`. */
330
- export function intOf(
331
- params: Readonly<Record<string, string>>,
332
- key: string,
333
- fallback: number,
334
- ): number {
335
- return int(params[key]) ?? fallback;
351
+ /** The stop reason a notification carries; 0 when it carries none. */
352
+ export function reasonOf(params: Readonly<Record<string, string>>): number {
353
+ // No bound on the digits: leading zeros are as good as none.
354
+ return uint32(params["reason"], Infinity) ?? 0;
336
355
  }
337
356
 
338
357
  /** Whether an SDP has an audio media section. */