@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.
- package/README.md +61 -8
- package/dist/capabilities.d.ts +13 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/client.d.ts +17 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/discovery/resolver.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/handshake/crypt-handshake.d.ts +9 -1
- package/dist/handshake/crypt-handshake.d.ts.map +1 -1
- package/dist/handshake.d.ts.map +1 -1
- package/dist/hooks.d.ts +7 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +762 -185
- package/dist/index.js.map +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/trace.d.ts +223 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/transfer.d.ts +80 -1
- package/dist/transfer.d.ts.map +1 -1
- package/dist/transport/handler.d.ts +2 -0
- package/dist/transport/handler.d.ts.map +1 -1
- package/dist/types.d.ts +17 -110
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/capabilities.ts +23 -2
- package/src/client.ts +392 -54
- package/src/crypto/crypt.ts +5 -5
- package/src/discovery/resolver.ts +56 -3
- package/src/errors.ts +92 -11
- package/src/handshake/crypt-handshake.ts +26 -5
- package/src/handshake/crypt-init2.ts +5 -5
- package/src/handshake.ts +3 -1
- package/src/hooks.ts +30 -0
- package/src/index.ts +30 -10
- package/src/notifications.ts +1 -1
- package/src/throttle.ts +2 -2
- package/src/trace.ts +238 -0
- package/src/transfer.ts +334 -25
- package/src/transport/handler.ts +13 -9
- package/src/transport/packet.ts +1 -1
- 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
|
|
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,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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 };
|