@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/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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 {
|
package/src/query/stream.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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);
|
package/src/query/transport.ts
CHANGED
|
@@ -53,8 +53,16 @@ export interface QueryTransport {
|
|
|
53
53
|
* run.
|
|
54
54
|
*/
|
|
55
55
|
readonly supportsNotifications: boolean;
|
|
56
|
-
/**
|
|
57
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
/**
|
|
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
|
-
{
|
|
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));
|
package/src/stream/published.ts
CHANGED
|
@@ -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
|
|
137
|
-
/** Requests are announced in the order they came, whatever the lookups take. */
|
|
138
|
-
#
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
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 =
|
package/src/stream/streams.ts
CHANGED
|
@@ -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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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)
|
|
426
|
-
this
|
|
427
|
-
//
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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()];
|
package/src/stream/wire.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
287
|
-
const access =
|
|
288
|
-
const 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
|
|
330
|
-
export function
|
|
331
|
-
|
|
332
|
-
|
|
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. */
|