@honeybbq/teamspeak-client 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +61 -8
  2. package/dist/capabilities.d.ts +13 -1
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/client.d.ts +17 -11
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/discovery/resolver.d.ts.map +1 -1
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/handshake/crypt-handshake.d.ts +9 -1
  10. package/dist/handshake/crypt-handshake.d.ts.map +1 -1
  11. package/dist/handshake.d.ts.map +1 -1
  12. package/dist/hooks.d.ts +7 -0
  13. package/dist/hooks.d.ts.map +1 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +762 -185
  17. package/dist/index.js.map +1 -1
  18. package/dist/notifications.d.ts +1 -1
  19. package/dist/notifications.d.ts.map +1 -1
  20. package/dist/trace.d.ts +223 -0
  21. package/dist/trace.d.ts.map +1 -0
  22. package/dist/transfer.d.ts +80 -1
  23. package/dist/transfer.d.ts.map +1 -1
  24. package/dist/transport/handler.d.ts +2 -0
  25. package/dist/transport/handler.d.ts.map +1 -1
  26. package/dist/types.d.ts +17 -110
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +11 -5
  29. package/src/capabilities.ts +23 -2
  30. package/src/client.ts +392 -54
  31. package/src/crypto/crypt.ts +5 -5
  32. package/src/discovery/resolver.ts +56 -3
  33. package/src/errors.ts +92 -11
  34. package/src/handshake/crypt-handshake.ts +26 -5
  35. package/src/handshake/crypt-init2.ts +5 -5
  36. package/src/handshake.ts +3 -1
  37. package/src/hooks.ts +30 -0
  38. package/src/index.ts +30 -10
  39. package/src/notifications.ts +1 -1
  40. package/src/throttle.ts +2 -2
  41. package/src/trace.ts +238 -0
  42. package/src/transfer.ts +334 -25
  43. package/src/transport/handler.ts +13 -9
  44. package/src/transport/packet.ts +1 -1
  45. package/src/types.ts +21 -123
package/src/trace.ts ADDED
@@ -0,0 +1,238 @@
1
+ import type { ChannelID, ClientID } from "@honeybbq/teamspeak-core";
2
+ import type { ServerCapabilities } from "./capabilities.js";
3
+ import type { ResolvedAddr } from "./types.js";
4
+
5
+ /** The values of the table below, before freezing. */
6
+ const connectPhaseValues = {
7
+ /** Opening the socket, or the address was not usable once resolved. */
8
+ Open: "open",
9
+ /** The handshake, from the first packet until the shared secret exists. */
10
+ Handshake: "handshake",
11
+ /** After the handshake, waiting for the server to accept the client: a wrong password, a ban or an invalid nickname end here. */
12
+ Join: "join",
13
+ } as const;
14
+ /** Where a connection attempt can fail; see {@link AttemptFailedInfo}. */
15
+ export const ConnectPhase: typeof connectPhaseValues =
16
+ /* @__PURE__ */ Object.freeze(connectPhaseValues);
17
+ /**
18
+ * One of {@link ConnectPhase}. May gain members in a minor release: a
19
+ * `switch` over it needs a default case.
20
+ */
21
+ export type ConnectPhase = (typeof ConnectPhase)[keyof typeof ConnectPhase];
22
+
23
+ /** What every milestone of one connection attempt says about the attempt. */
24
+ interface AttemptInfo {
25
+ /**
26
+ * The attempt's number, from 1. The attempt `connect()` starts is 1; a
27
+ * reconnection attempt carries the retry number the reconnect policy uses,
28
+ * the one `shouldRetry` and the backoff see.
29
+ */
30
+ readonly attempt: number;
31
+ /** Whether the reconnect loop started this attempt rather than a call to `connect()`. */
32
+ readonly reconnect: boolean;
33
+ }
34
+
35
+ /** The address is about to be resolved. */
36
+ export interface ResolveStartInfo extends AttemptInfo {
37
+ /** The address as the client was given it. */
38
+ readonly addr: string;
39
+ }
40
+
41
+ /** The address has been resolved, or the lookup failed and the address is used as given. */
42
+ export interface ResolveDoneInfo extends AttemptInfo {
43
+ /** The address as the client was given it. */
44
+ readonly addr: string;
45
+ /** The `host:port` that will be dialled. */
46
+ readonly target: string;
47
+ /** What the resolver reports as the source of the answer dialled; may be empty. */
48
+ readonly source: string;
49
+ /** Every answer the resolver gave, in order; empty when the address is used as given. */
50
+ readonly candidates: readonly ResolvedAddr[];
51
+ /** Why the lookup failed, when the address is used as given; `undefined` otherwise. */
52
+ readonly error: unknown;
53
+ /** How long the resolution took, in milliseconds. */
54
+ readonly durationMs: number;
55
+ }
56
+
57
+ /** The socket is about to be opened. */
58
+ export interface ConnectStartInfo extends AttemptInfo {
59
+ /** The `host:port` being dialled. */
60
+ readonly target: string;
61
+ }
62
+
63
+ /** The socket is open; the first handshake packet has not been sent yet. */
64
+ export interface ConnectDoneInfo extends AttemptInfo {
65
+ /** The `host:port` being dialled. */
66
+ readonly target: string;
67
+ /** The local socket's `host:port`; empty when the socket does not say. */
68
+ readonly localAddr: string;
69
+ /** Milliseconds since `connectStart`. */
70
+ readonly durationMs: number;
71
+ }
72
+
73
+ /** The encrypted session exists; `clientinit` has not been sent yet. */
74
+ export interface HandshakeDoneInfo extends AttemptInfo {
75
+ /** Milliseconds since `connectStart`. */
76
+ readonly durationMs: number;
77
+ /** The level of the RSA puzzle the server demanded; 0 if it sent none. */
78
+ readonly puzzleLevel: number;
79
+ /** Milliseconds spent solving the puzzle. */
80
+ readonly puzzleDurationMs: number;
81
+ /**
82
+ * The packets sent again from `connectStart` to now, for want of an
83
+ * acknowledgement: the Init1 steps and the handshake's command packets.
84
+ */
85
+ readonly resends: number;
86
+ /**
87
+ * The server's public key exactly as it sent it, base64, so that a caller can
88
+ * pin a server's identity.
89
+ */
90
+ readonly serverPublicKey: string;
91
+ }
92
+
93
+ /** The server accepted the client, which is now connected. */
94
+ export interface SessionStartInfo extends AttemptInfo {
95
+ /** The client's ID on the server, the same as `client.clientID`. */
96
+ readonly clientID: ClientID;
97
+ /**
98
+ * The nickname the server accepted (`acn`), which differs from the one asked
99
+ * for when that was taken. The one asked for when the server did not say.
100
+ */
101
+ readonly nickname: string;
102
+ /** The client's talk power (`client_talk_power`); 0 when the server did not say. */
103
+ readonly talkPower: number;
104
+ /**
105
+ * Every parameter of `initserver`, unescaped, except those that could carry a
106
+ * secret: `client_server_password`, `client_default_channel_password`,
107
+ * `client_default_token` and `client_key_offset`. Frozen.
108
+ */
109
+ readonly initServer: Readonly<Record<string, string>>;
110
+ /** What the server said about itself in this handshake. */
111
+ readonly capabilities: ServerCapabilities;
112
+ /** Milliseconds since `connectStart`. */
113
+ readonly durationMs: number;
114
+ }
115
+
116
+ /** The server's welcome sequence ended: the channel list and the clients already there have arrived. */
117
+ export interface WelcomeDoneInfo extends AttemptInfo {
118
+ /**
119
+ * The channel the client is in at the end of the welcome: where it landed,
120
+ * which differs from the default channel asked for when that was refused.
121
+ */
122
+ readonly channelID: ChannelID;
123
+ /** How many channel rows the welcome delivered. */
124
+ readonly channels: number;
125
+ /** How many clients are in view at the end of the welcome, this client included. */
126
+ readonly clients: number;
127
+ /** Milliseconds since `connectStart`. */
128
+ readonly durationMs: number;
129
+ }
130
+
131
+ /** An attempt ended without a session. */
132
+ export interface AttemptFailedInfo extends AttemptInfo {
133
+ /** Where the attempt failed. */
134
+ readonly phase: ConnectPhase;
135
+ /** Why: the error `connect()` rejects with, or the one that ended the reconnection attempt. */
136
+ readonly error: unknown;
137
+ /** The `host:port` being dialled; empty when the attempt failed before `resolveDone`. */
138
+ readonly target: string;
139
+ /** Milliseconds since `resolveStart`. */
140
+ readonly durationMs: number;
141
+ }
142
+
143
+ /** The reconnect loop will retry, after a wait. */
144
+ export interface ReconnectScheduledInfo {
145
+ /** The attempt that will follow, numbered as in {@link ResolveStartInfo.attempt}. */
146
+ readonly attempt: number;
147
+ /** How long the loop waits first, in milliseconds. */
148
+ readonly delayMs: number;
149
+ /** The error that ended the previous connection or attempt. */
150
+ readonly cause: unknown;
151
+ }
152
+
153
+ /** A connected session is about to be left with `disconnect()`. It has no fields yet. */
154
+ // oxlint-disable-next-line no-empty-interface -- fields are added here later
155
+ export interface DisconnectStartInfo {}
156
+
157
+ /** The goodbye of `disconnect()` is over. */
158
+ export interface DisconnectDoneInfo {
159
+ /** Whether the server acknowledged the goodbye before the goodbye timeout. */
160
+ readonly acknowledged: boolean;
161
+ /** Milliseconds since `disconnectStart`. */
162
+ readonly durationMs: number;
163
+ }
164
+
165
+ /** A session that had a `sessionStart` has ended, however it ended. */
166
+ export interface SessionEndInfo {
167
+ /** The client's ID on the server during the session. */
168
+ readonly clientID: ClientID;
169
+ /**
170
+ * `undefined` after `disconnect()`, and otherwise the cause the
171
+ * `disconnected` event gets.
172
+ */
173
+ readonly error: unknown;
174
+ /** Milliseconds since `sessionStart`. */
175
+ readonly durationMs: number;
176
+ /** The number of the attempt that started the session. */
177
+ readonly attempt: number;
178
+ /** Whether the reconnect loop started the attempt that started the session. */
179
+ readonly reconnect: boolean;
180
+ }
181
+
182
+ /** The reconnect loop ended without a session. */
183
+ export interface ReconnectStoppedInfo {
184
+ /** How many reconnection attempts the loop made. */
185
+ readonly attempts: number;
186
+ /**
187
+ * The error `shouldRetry` declined, or the last attempt's when the attempts
188
+ * ran out; `undefined` when `disconnect()` stopped the loop.
189
+ */
190
+ readonly cause: unknown;
191
+ }
192
+
193
+ /**
194
+ * Hooks that observe a client for timing, metrics, logs or a progress
195
+ * display: the milestones of every connection attempt, the end of a session,
196
+ * and the reconnect loop. Every hook is optional.
197
+ *
198
+ * A hook is called synchronously, at the moment of the milestone, and the
199
+ * client waits for it to return: keep it quick, and hand slow work to your own
200
+ * task. What it returns is ignored, and a returned promise is never awaited. A
201
+ * hook that throws, or whose promise rejects, is reported the way a throwing
202
+ * event listener is, and the client carries on. A hook may read the
203
+ * client's state. It must not call `connect()` or `disconnect()`: to give up
204
+ * on an attempt, abort the signal passed to `connect()`, and to leave once
205
+ * connected, call `disconnect()` from a `connected` listener.
206
+ *
207
+ * Every attempt ends with exactly one of `sessionStart` and `attemptFailed`,
208
+ * and every session that started with exactly one `sessionEnd`. The hooks are
209
+ * read when the client is made, from a copy of this object.
210
+ */
211
+ export interface ClientTrace {
212
+ /** Before the address is resolved. */
213
+ readonly resolveStart?: (info: ResolveStartInfo) => void;
214
+ /** After the address is resolved, also when it fell back to the address as given. */
215
+ readonly resolveDone?: (info: ResolveDoneInfo) => void;
216
+ /** Right before the socket is opened. */
217
+ readonly connectStart?: (info: ConnectStartInfo) => void;
218
+ /** Once the socket is open, before the first handshake packet is sent. */
219
+ readonly connectDone?: (info: ConnectDoneInfo) => void;
220
+ /** When the shared secret is derived, before `clientinit` is sent. */
221
+ readonly handshakeDone?: (info: HandshakeDoneInfo) => void;
222
+ /** When the server accepts the client: before `connect()` resolves and before `connected` is delivered. */
223
+ readonly sessionStart?: (info: SessionStartInfo) => void;
224
+ /** When the server's welcome sequence ends; at most once per attempt, and only after `sessionStart`. */
225
+ readonly welcomeDone?: (info: WelcomeDoneInfo) => void;
226
+ /** When an attempt ends without `sessionStart`, for any reason, an abort or `disconnect()` included. */
227
+ readonly attemptFailed?: (info: AttemptFailedInfo) => void;
228
+ /** When `disconnect()` begins saying goodbye to a connected session. */
229
+ readonly disconnectStart?: (info: DisconnectStartInfo) => void;
230
+ /** When that goodbye is over. */
231
+ readonly disconnectDone?: (info: DisconnectDoneInfo) => void;
232
+ /** When a session ends, however it ends; before `disconnected` is queued. */
233
+ readonly sessionEnd?: (info: SessionEndInfo) => void;
234
+ /** When the reconnect loop has decided to retry and is about to wait. */
235
+ readonly reconnectScheduled?: (info: ReconnectScheduledInfo) => void;
236
+ /** When the reconnect loop ends without a session. */
237
+ readonly reconnectStopped?: (info: ReconnectStoppedInfo) => void;
238
+ }
package/src/transfer.ts CHANGED
@@ -8,14 +8,96 @@
8
8
 
9
9
  import { connect } from "node:net";
10
10
  import { pipeline } from "node:stream/promises";
11
- import type { Duplex, Readable, Writable } from "node:stream";
11
+ import { Transform, type Duplex, type Readable, type Writable } from "node:stream";
12
12
  import type { FileUploadInfo, FileDownloadInfo } from "@honeybbq/teamspeak-core";
13
13
  import type { FileTransferSocketFactory } from "./types.js";
14
14
  import { FileTransferError } from "./errors.js";
15
+ import { callHook } from "./hooks.js";
16
+ import { joinHostPort } from "./address.js";
17
+ import { abortable } from "./abort.js";
15
18
 
16
19
  /** How long dialing may take before it fails, unless a signal says otherwise. */
17
20
  const DIAL_TIMEOUT_MS = 10_000;
18
21
 
22
+ /** How long a transfer may go without moving a byte, unless `idleTimeoutMs` says otherwise. */
23
+ const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
24
+
25
+ /** How many bytes move between two calls of the `progress` hook, at least. */
26
+ const PROGRESS_STEP_BYTES = 64 * 1024;
27
+
28
+ /** Where a failed transfer failed. */
29
+ export type FileTransferStage = "dial" | "transfer";
30
+
31
+ /** The file transfer port is about to be dialled. */
32
+ export interface FileTransferDialStartInfo {
33
+ /** The `host:port` being dialled. */
34
+ readonly addr: string;
35
+ }
36
+
37
+ /** The connection is open and the transfer key has been sent. */
38
+ export interface FileTransferDialDoneInfo {
39
+ /** The `host:port` dialled. */
40
+ readonly addr: string;
41
+ /** The local socket's `host:port`; empty when the socket does not say. */
42
+ readonly localAddr: string;
43
+ /** Milliseconds since `dialStart`. */
44
+ readonly durationMs: number;
45
+ }
46
+
47
+ /** Data has moved. */
48
+ export interface FileTransferProgressInfo {
49
+ /** The bytes moved so far. */
50
+ readonly bytes: number;
51
+ /** The size the negotiation gave; 0 when it is not known, as for an upload. */
52
+ readonly total: number;
53
+ }
54
+
55
+ /** The transfer succeeded. */
56
+ export interface FileTransferDoneInfo {
57
+ /** The bytes moved. */
58
+ readonly bytes: number;
59
+ /** Milliseconds since `dialStart`. */
60
+ readonly durationMs: number;
61
+ }
62
+
63
+ /** The transfer failed. */
64
+ export interface FileTransferFailedInfo {
65
+ /** Why: the error the transfer function rejects with. */
66
+ readonly error: unknown;
67
+ /** The bytes moved before it failed. */
68
+ readonly bytes: number;
69
+ /** Milliseconds since `dialStart`. */
70
+ readonly durationMs: number;
71
+ /** Whether the failure came while dialling or while the data moved. */
72
+ readonly stage: FileTransferStage;
73
+ }
74
+
75
+ /**
76
+ * Hooks that observe one file transfer. Every hook is optional, and none ever
77
+ * sees the transfer key.
78
+ *
79
+ * A hook runs synchronously on the task doing the transfer, and its return
80
+ * value is ignored: a returned promise is not awaited. A hook that throws, or
81
+ * whose promise rejects, is reported the way a throwing event listener is, and
82
+ * the transfer goes on. Keep hooks quick.
83
+ *
84
+ * A call of `uploadFileData` or `downloadFileData` ends with exactly one of
85
+ * `done` and `failed`; `dialFileTransfer` on its own reports `dialStart`, then
86
+ * `dialDone` or `failed` with the stage `"dial"`.
87
+ */
88
+ export interface FileTransferTrace {
89
+ /** Before the file transfer port is dialled. */
90
+ readonly dialStart?: (info: FileTransferDialStartInfo) => void;
91
+ /** When the connection is open and the transfer key is sent. */
92
+ readonly dialDone?: (info: FileTransferDialDoneInfo) => void;
93
+ /** While data moves, at most once per 64 KiB, and once more at the end with the final count. */
94
+ readonly progress?: (info: FileTransferProgressInfo) => void;
95
+ /** When the transfer function is about to resolve. */
96
+ readonly done?: (info: FileTransferDoneInfo) => void;
97
+ /** When the transfer function is about to reject, a failed dial included. */
98
+ readonly failed?: (info: FileTransferFailedInfo) => void;
99
+ }
100
+
19
101
  /** Options for a file transfer. */
20
102
  export interface FileTransferOptions {
21
103
  /** Abandons the transfer; it then rejects with the signal's reason. */
@@ -25,6 +107,83 @@ export interface FileTransferOptions {
25
107
  * through a proxy or tunnel.
26
108
  */
27
109
  readonly socketFactory?: FileTransferSocketFactory | undefined;
110
+ /**
111
+ * Hooks called at each milestone of this transfer. The object is copied when
112
+ * the call starts. See {@link FileTransferTrace}.
113
+ */
114
+ readonly trace?: FileTransferTrace | undefined;
115
+ /**
116
+ * The longest, in milliseconds, the transfer may go without moving a byte,
117
+ * the write of the transfer key included. When it passes, the transfer
118
+ * rejects with a {@link FileTransferError} whose `cause` is a `TimeoutError`
119
+ * and the connection is destroyed. `undefined` means the default, 30 000;
120
+ * `0` turns the limit off.
121
+ */
122
+ readonly idleTimeoutMs?: number | undefined;
123
+ }
124
+
125
+ /** What a transfer's hooks share: the trace, when the call began, and what has moved. */
126
+ interface TransferTrace {
127
+ readonly trace: FileTransferTrace | undefined;
128
+ readonly startedAt: number;
129
+ bytes: number;
130
+ }
131
+
132
+ /**
133
+ * Watches a transfer for going idle. `touch()` says a byte moved; when none
134
+ * does for the whole limit, `signal` aborts with a `TimeoutError`.
135
+ */
136
+ interface IdleGuard {
137
+ readonly signal: AbortSignal;
138
+ readonly limitMs: number;
139
+ touch(): void;
140
+ stop(): void;
141
+ }
142
+
143
+ function newIdleGuard(options: FileTransferOptions): IdleGuard {
144
+ const limitMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
145
+ if (!Number.isFinite(limitMs) || limitMs < 0) {
146
+ throw new RangeError("idleTimeoutMs must be a finite number of milliseconds, 0 or more");
147
+ }
148
+ const controller = new AbortController();
149
+ if (limitMs === 0) {
150
+ return { signal: controller.signal, limitMs, touch() {}, stop() {} };
151
+ }
152
+ const timer = setTimeout(
153
+ () => controller.abort(new DOMException(`no data moved for ${limitMs} ms`, "TimeoutError")),
154
+ limitMs,
155
+ );
156
+ return {
157
+ signal: controller.signal,
158
+ limitMs,
159
+ touch: () => timer.refresh(),
160
+ stop: () => clearTimeout(timer),
161
+ };
162
+ }
163
+
164
+ /** The error a transfer fails with when `idle` expired, else `error` as it is. */
165
+ function idleOr(idle: IdleGuard, error: unknown): unknown {
166
+ if (!idle.signal.aborted) return error;
167
+ return new FileTransferError(`file transfer stalled: no data moved for ${idle.limitMs} ms`, {
168
+ cause: idle.signal.reason,
169
+ });
170
+ }
171
+
172
+ function newTransferTrace(options: FileTransferOptions): TransferTrace {
173
+ return {
174
+ trace: options.trace === undefined ? undefined : { ...options.trace },
175
+ startedAt: performance.now(),
176
+ bytes: 0,
177
+ };
178
+ }
179
+
180
+ function reportFailed(t: TransferTrace, error: unknown, stage: FileTransferStage): void {
181
+ callHook(t.trace?.failed, t.trace, () => ({
182
+ error,
183
+ bytes: t.bytes,
184
+ durationMs: performance.now() - t.startedAt,
185
+ stage,
186
+ }));
28
187
  }
29
188
 
30
189
  /**
@@ -36,8 +195,37 @@ export async function dialFileTransfer(
36
195
  port: number,
37
196
  key: string,
38
197
  options: FileTransferOptions = {},
198
+ ): Promise<Duplex> {
199
+ const t = newTransferTrace(options);
200
+ const idle = newIdleGuard(options);
201
+ try {
202
+ return await dial(host, port, key, options, t, idle);
203
+ } catch (error) {
204
+ const failure = idleOr(idle, error);
205
+ reportFailed(t, failure, "dial");
206
+ throw failure;
207
+ } finally {
208
+ idle.stop();
209
+ }
210
+ }
211
+
212
+ async function dial(
213
+ host: string,
214
+ port: number,
215
+ key: string,
216
+ options: FileTransferOptions,
217
+ t: TransferTrace,
218
+ idle: IdleGuard,
39
219
  ): Promise<Duplex> {
40
220
  const { signal, socketFactory } = options;
221
+ // One budget for the connection and the key write: a peer that accepts the
222
+ // connection and never reads would otherwise hold the write forever.
223
+ const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
224
+ const dialSignal = AbortSignal.any(
225
+ signal ? [signal, timeout, idle.signal] : [timeout, idle.signal],
226
+ );
227
+ const addr = joinHostPort(host, String(port));
228
+ callHook(t.trace?.dialStart, t.trace, () => ({ addr }));
41
229
  signal?.throwIfAborted();
42
230
  const socket: Duplex = socketFactory ? socketFactory(host, port) : connect({ host, port });
43
231
 
@@ -46,42 +234,154 @@ export async function dialFileTransfer(
46
234
  const swallow = (): void => {};
47
235
  socket.on("error", swallow);
48
236
  try {
49
- await connected(socket, signal);
50
- await new Promise<void>((resolve, reject) => {
51
- socket.write(key, (err) =>
52
- err
53
- ? reject(
54
- new FileTransferError(`failed to send transfer key: ${err.message}`, { cause: err }),
55
- )
56
- : resolve(),
57
- );
58
- });
237
+ await abortable(connected(socket), dialSignal);
238
+ idle.touch();
239
+ await abortable(
240
+ new Promise<void>((resolve, reject) => {
241
+ socket.write(key, (err) =>
242
+ err
243
+ ? reject(
244
+ new FileTransferError(`failed to send transfer key: ${err.message}`, {
245
+ cause: err,
246
+ }),
247
+ )
248
+ : resolve(),
249
+ );
250
+ }),
251
+ dialSignal,
252
+ );
253
+ idle.touch();
59
254
  } catch (error) {
60
255
  socket.destroy();
256
+ if (timeout.aborted && error === timeout.reason) {
257
+ throw new FileTransferError("file transfer connection timed out", { cause: error });
258
+ }
61
259
  throw error;
62
260
  } finally {
63
261
  socket.off("error", swallow);
64
262
  }
263
+ callHook(t.trace?.dialDone, t.trace, () => {
264
+ let localAddr = "";
265
+ const local = socket as { localAddress?: unknown; localPort?: unknown };
266
+ if (typeof local.localAddress === "string" && typeof local.localPort === "number") {
267
+ localAddr = joinHostPort(local.localAddress, String(local.localPort));
268
+ }
269
+ return { addr, localAddr, durationMs: performance.now() - t.startedAt };
270
+ });
65
271
  return socket;
66
272
  }
67
273
 
274
+ /**
275
+ * A pass-through that counts what moves and reports it to `progress`. It never
276
+ * fails the pipeline: a hook's error is reported, not thrown.
277
+ */
278
+ function countingStream(t: TransferTrace, total: number, idle: IdleGuard): Transform {
279
+ let reported = 0;
280
+ return new Transform({
281
+ transform(chunk: Buffer | string, _encoding, callback) {
282
+ t.bytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
283
+ idle.touch();
284
+ if (t.bytes - reported >= PROGRESS_STEP_BYTES) {
285
+ reported = t.bytes;
286
+ const bytes = t.bytes;
287
+ callHook(t.trace?.progress, t.trace, () => ({ bytes, total }));
288
+ }
289
+ callback(null, chunk);
290
+ },
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Passes exactly `expected` bytes. One more byte fails the transfer, and is
296
+ * not passed on; a stream that ends before `expected` fails it too.
297
+ */
298
+ function exactSize(expected: number): Transform {
299
+ let received = 0;
300
+ return new Transform({
301
+ transform(chunk: Buffer, _encoding, callback) {
302
+ const room = expected - received;
303
+ if (chunk.length > room) {
304
+ this.push(chunk.subarray(0, room));
305
+ received = expected;
306
+ callback(
307
+ new FileTransferError(`file transfer sent more than the announced ${expected} bytes`),
308
+ );
309
+ return;
310
+ }
311
+ received += chunk.length;
312
+ callback(null, chunk);
313
+ },
314
+ flush(callback) {
315
+ callback(
316
+ received === expected
317
+ ? null
318
+ : new FileTransferError(
319
+ `file transfer ended after ${received} of the announced ${expected} bytes`,
320
+ ),
321
+ );
322
+ },
323
+ });
324
+ }
325
+
326
+ /** Run a transfer's data phase, and report how the whole call ended. */
327
+ async function transfer(
328
+ host: string,
329
+ port: number,
330
+ key: string,
331
+ total: number,
332
+ options: FileTransferOptions,
333
+ move: (
334
+ socket: Duplex,
335
+ counter: Transform,
336
+ pipeOptions: { signal?: AbortSignal },
337
+ ) => Promise<void>,
338
+ ): Promise<void> {
339
+ const t = newTransferTrace(options);
340
+ const idle = newIdleGuard(options);
341
+ try {
342
+ let socket: Duplex;
343
+ try {
344
+ socket = await dial(host, port, key, options, t, idle);
345
+ } catch (error) {
346
+ const failure = idleOr(idle, error);
347
+ reportFailed(t, failure, "dial");
348
+ throw failure;
349
+ }
350
+ try {
351
+ const signal = options.signal ? AbortSignal.any([options.signal, idle.signal]) : idle.signal;
352
+ await move(socket, countingStream(t, total, idle), { signal });
353
+ } catch (error) {
354
+ // A pipeline aborted by the caller's signal rejects as the signal says;
355
+ // one aborted by the idle limit is the transfer's own failure.
356
+ const failure = idleOr(idle, error);
357
+ reportFailed(t, failure, "transfer");
358
+ throw failure;
359
+ }
360
+ } finally {
361
+ idle.stop();
362
+ }
363
+ const bytes = t.bytes;
364
+ callHook(t.trace?.progress, t.trace, () => ({ bytes, total }));
365
+ callHook(t.trace?.done, t.trace, () => ({
366
+ bytes,
367
+ durationMs: performance.now() - t.startedAt,
368
+ }));
369
+ }
370
+
68
371
  /**
69
372
  * Wait for a stream to be connected. A `node:net` socket says so with
70
373
  * `connect`; a stream from elsewhere that is already writable is taken as
71
374
  * connected.
72
375
  */
73
- function connected(socket: Duplex, signal: AbortSignal | undefined): Promise<void> {
376
+ function connected(socket: Duplex): Promise<void> {
74
377
  const pending = (socket as { pending?: boolean }).pending;
75
378
  if (pending === false || (pending === undefined && socket.writable)) return Promise.resolve();
76
379
 
77
380
  return new Promise<void>((resolve, reject) => {
78
- const timeout = AbortSignal.timeout(DIAL_TIMEOUT_MS);
79
381
  const done = (error?: unknown): void => {
80
382
  socket.off("connect", onConnect);
81
383
  socket.off("ready", onConnect);
82
384
  socket.off("error", onError);
83
- signal?.removeEventListener("abort", onAbort);
84
- timeout.removeEventListener("abort", onTimeout);
85
385
  if (error === undefined) resolve();
86
386
  else reject(error);
87
387
  };
@@ -92,36 +392,45 @@ function connected(socket: Duplex, signal: AbortSignal | undefined): Promise<voi
92
392
  cause: error,
93
393
  }),
94
394
  );
95
- const onAbort = (): void => done(signal!.reason);
96
- const onTimeout = (): void => done(new FileTransferError("file transfer connection timed out"));
97
395
  socket.once("connect", onConnect);
98
396
  socket.once("ready", onConnect);
99
397
  socket.once("error", onError);
100
- signal?.addEventListener("abort", onAbort, { once: true });
101
- timeout.addEventListener("abort", onTimeout, { once: true });
102
398
  });
103
399
  }
104
400
 
105
401
  /** Upload `data` over a negotiated transfer. Resolves once every byte is sent. */
106
- export async function uploadFileData(
402
+ export function uploadFileData(
107
403
  host: string,
108
404
  info: FileUploadInfo,
109
405
  data: Readable,
110
406
  options: FileTransferOptions = {},
111
407
  ): Promise<void> {
112
- const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
113
- await pipeline(data, socket, options.signal ? { signal: options.signal } : {});
408
+ return transfer(
409
+ host,
410
+ info.port,
411
+ info.fileTransferKey,
412
+ 0,
413
+ options,
414
+ (socket, counter, pipeOptions) => pipeline(data, counter, socket, pipeOptions),
415
+ );
114
416
  }
115
417
 
116
418
  /** Download a negotiated transfer into `dest`. Resolves once the file is written. */
117
- export async function downloadFileData(
419
+ export function downloadFileData(
118
420
  host: string,
119
421
  info: FileDownloadInfo,
120
422
  dest: Writable,
121
423
  options: FileTransferOptions = {},
122
424
  ): Promise<void> {
123
- const socket = await dialFileTransfer(host, info.port, info.fileTransferKey, options);
124
- await pipeline(socket, dest, options.signal ? { signal: options.signal } : {});
425
+ return transfer(
426
+ host,
427
+ info.port,
428
+ info.fileTransferKey,
429
+ Number(info.size),
430
+ options,
431
+ (socket, counter, pipeOptions) =>
432
+ pipeline(socket, exactSize(Number(info.size)), counter, dest, pipeOptions),
433
+ );
125
434
  }
126
435
 
127
436
  export { FileTransferError };