@linxin666/dsh-ssh 0.1.13 → 0.1.15

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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Connection pool: per-alias persistent ssh2 connections with multi-hop jump
3
+ * support, the acquire / dispose / sweep lifecycle, and the pooled exec path.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { Client, type ConnectConfig } from 'ssh2'
8
+ import type { ExecResult, SshHostEntry } from '../protocol.ts'
9
+ import { expandHome, type HostStore } from '../store.ts'
10
+
11
+ /** Default engine knobs. */
12
+ export interface EngineOptions {
13
+ /** Connections idle longer than this are closed (ms). */
14
+ idleTimeoutMs?: number
15
+ /** SSH handshake timeout (ms). */
16
+ connectTimeoutMs?: number
17
+ /** Keepalive ping interval (ms). */
18
+ keepaliveIntervalMs?: number
19
+ /** Cap on captured stdout/stderr bytes per exec (ms). */
20
+ maxOutputBytes?: number
21
+ /** Default exec timeout (ms). */
22
+ defaultExecTimeoutMs?: number
23
+ /** Default cluster concurrency. */
24
+ defaultMaxWorkers?: number
25
+ /** SFTP concurrent channel count for transfers. */
26
+ sftpConcurrency?: number
27
+ }
28
+
29
+ /** Default engine knobs (applied when an option is omitted). */
30
+ export const DEFAULTS: Required<EngineOptions> = {
31
+ idleTimeoutMs: 30 * 60_000,
32
+ connectTimeoutMs: 15_000,
33
+ keepaliveIntervalMs: 15_000,
34
+ maxOutputBytes: 2 * 1024 * 1024,
35
+ defaultExecTimeoutMs: 60_000,
36
+ defaultMaxWorkers: 8,
37
+ sftpConcurrency: 8,
38
+ }
39
+
40
+ /** One pooled connection record. */
41
+ export interface PoolRecord {
42
+ client: Client
43
+ /** Jump-chain clients kept alive under the target. */
44
+ hops: Client[]
45
+ idleAt: number
46
+ /** Pinned connections (tunnels) are never swept. */
47
+ pinned: boolean
48
+ broken: boolean
49
+ /** Operations currently running on this connection (sweep guard). */
50
+ inFlight: number
51
+ }
52
+
53
+ /**
54
+ * The slice of the engine the pool and exec paths need. The host class
55
+ * (engine.ts facade) satisfies this structurally.
56
+ */
57
+ export interface PoolEngine {
58
+ readonly store: HostStore
59
+ readonly opts: Required<EngineOptions>
60
+ readonly pool: Map<string, PoolRecord>
61
+ readonly acquireQueue: Map<string, Promise<PoolRecord>>
62
+ }
63
+
64
+ /** Build the ssh2 connect config for one entry (key read from disk). */
65
+ export function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
66
+ const config: ConnectConfig = {
67
+ host: entry.host,
68
+ port: entry.port,
69
+ username: entry.user,
70
+ readyTimeout: 15_000,
71
+ keepaliveInterval: 15_000,
72
+ keepaliveCountMax: 3,
73
+ }
74
+ if (sock !== undefined) config.sock = sock
75
+ if (entry.auth.kind === 'password') {
76
+ config.password = entry.auth.password
77
+ } else {
78
+ const keyPath = entry.auth.keyPath === undefined ? undefined : expandHome(entry.auth.keyPath)
79
+ if (keyPath === undefined || !existsSync(keyPath)) {
80
+ throw new Error('private key not found: ' + (entry.auth.keyPath ?? '(unset)'))
81
+ }
82
+ config.privateKey = readFileSync(keyPath, 'utf8')
83
+ if (entry.auth.passphrase !== undefined && entry.auth.passphrase !== '') {
84
+ config.passphrase = entry.auth.passphrase
85
+ }
86
+ }
87
+ return config
88
+ }
89
+
90
+ /** Connect one ssh2 client (resolve on ready, reject on error/close). */
91
+ function connectClient(config: ConnectConfig): Promise<Client> {
92
+ return new Promise((resolve, reject) => {
93
+ const client = new Client()
94
+ let settled = false
95
+ client.once('ready', () => {
96
+ if (settled) return
97
+ settled = true
98
+ resolve(client)
99
+ })
100
+ client.once('error', (error) => {
101
+ if (settled) return
102
+ settled = true
103
+ reject(error instanceof Error ? error : new Error(String(error)))
104
+ })
105
+ try {
106
+ client.connect(config)
107
+ } catch (error) {
108
+ if (!settled) {
109
+ settled = true
110
+ reject(error instanceof Error ? error : new Error(String(error)))
111
+ }
112
+ }
113
+ })
114
+ }
115
+
116
+ /** Cap captured output at the configured byte budget (marks truncation). */
117
+ export function appendOutput(target: { text: string; truncated: boolean }, chunk: Buffer, maxBytes: number): void {
118
+ if (target.truncated) return
119
+ if (target.text.length + chunk.length > maxBytes) {
120
+ let cut = chunk.toString('utf8').slice(0, maxBytes - target.text.length)
121
+ // Never split a surrogate pair at the cut boundary.
122
+ if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
123
+ target.text += cut + '…[output truncated]'
124
+ target.truncated = true
125
+ return
126
+ }
127
+ target.text += chunk.toString('utf8')
128
+ }
129
+
130
+ /**
131
+ * Build one full jump chain for an entry: hop clients connected through in
132
+ * order, each forwarding a stream to the next destination, ending with the
133
+ * target client. Shared by the pool and standalone shell sessions.
134
+ */
135
+ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
136
+ const hops: Client[] = []
137
+ let sock: ConnectConfig['sock']
138
+ const chain = entry.proxyJump
139
+ for (let index = 0; index < chain.length; index += 1) {
140
+ const hopAlias = chain[index]
141
+ const hop = engine.store.find(hopAlias)
142
+ if (hop === undefined) {
143
+ for (const client of hops) client.end()
144
+ throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
145
+ }
146
+ const hopClient = await connectClient(buildConnectConfig(hop, sock))
147
+ hops.push(hopClient)
148
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
149
+ const nextHost = next !== undefined ? next.host : entry.host
150
+ const nextPort = next !== undefined ? next.port : entry.port
151
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
152
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
153
+ if (error !== undefined) {
154
+ for (const client of hops) client.end()
155
+ reject(error)
156
+ } else {
157
+ resolve(stream)
158
+ }
159
+ })
160
+ })
161
+ }
162
+ try {
163
+ const client = await connectClient(buildConnectConfig(entry, sock))
164
+ return { client, hops }
165
+ } catch (error) {
166
+ for (const client of hops) client.end()
167
+ throw error
168
+ }
169
+ }
170
+
171
+ /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
172
+ export async function acquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
173
+ const pending = engine.acquireQueue.get(alias)
174
+ if (pending !== undefined) return pending
175
+ const task = doAcquire(engine, alias)
176
+ engine.acquireQueue.set(alias, task)
177
+ try {
178
+ return await task
179
+ } finally {
180
+ if (engine.acquireQueue.get(alias) === task) engine.acquireQueue.delete(alias)
181
+ }
182
+ }
183
+
184
+ async function doAcquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
185
+ const entry = engine.store.find(alias)
186
+ if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
187
+ const { client, hops } = await connectChain(engine, entry)
188
+ const record: PoolRecord = { client, hops, idleAt: Date.now(), pinned: false, broken: false, inFlight: 0 }
189
+ client.on('error', () => { record.broken = true })
190
+ client.on('close', () => { record.broken = true })
191
+ engine.pool.set(alias, record)
192
+ return record
193
+ }
194
+
195
+ /**
196
+ * Tear down one alias's record. When `record` is given and no longer the
197
+ * pooled record for the alias (a concurrent acquire replaced it), nothing
198
+ * is torn down — the connection belongs to someone else now.
199
+ */
200
+ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void {
201
+ const current = engine.pool.get(alias)
202
+ if (record !== undefined && current !== record) return
203
+ if (current === undefined) return
204
+ engine.pool.delete(alias)
205
+ try { current.client.end() } catch { /* already closed */ }
206
+ for (const hop of current.hops) {
207
+ try { hop.end() } catch { /* already closed */ }
208
+ }
209
+ }
210
+
211
+ /** Close connections idle beyond the threshold (skips pinned and in-flight). */
212
+ export function sweepPool(engine: PoolEngine): void {
213
+ const cutoff = Date.now() - engine.opts.idleTimeoutMs
214
+ for (const [alias, record] of engine.pool) {
215
+ if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) {
216
+ disposeRecord(engine, alias, record)
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Run `fn` with a live client for `alias`, reconnecting (up to the
223
+ * attempt budget) when the connection broke mid-flight.
224
+ */
225
+ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
226
+ let lastError: unknown
227
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
228
+ let record = engine.pool.get(alias)
229
+ if (record === undefined || record.broken) {
230
+ if (record !== undefined) disposeRecord(engine, alias, record)
231
+ record = await acquire(engine, alias)
232
+ }
233
+ record.idleAt = Date.now()
234
+ record.inFlight += 1
235
+ try {
236
+ const result = await fn(record.client)
237
+ record.idleAt = Date.now()
238
+ return result
239
+ } finally {
240
+ record.inFlight -= 1
241
+ }
242
+ }
243
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
244
+ }
245
+
246
+ /** Run one command on `alias` (reusing the pooled connection). */
247
+ export async function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
248
+ const started = Date.now()
249
+ const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs
250
+ return withClient(engine, alias, async (client) => {
251
+ return await new Promise<ExecResult>((resolve, reject) => {
252
+ client.exec(command, (error, stream) => {
253
+ if (error !== undefined) {
254
+ reject(error)
255
+ return
256
+ }
257
+ const stdout = { text: '', truncated: false }
258
+ const stderr = { text: '', truncated: false }
259
+ let timedOut = false
260
+ let settled = false
261
+ const finish = (): void => {
262
+ if (settled) return
263
+ settled = true
264
+ clearTimeout(timer)
265
+ resolve({
266
+ success: false,
267
+ exitCode: null,
268
+ timedOut,
269
+ stdout: stdout.text,
270
+ stderr: stderr.text,
271
+ durationMs: Date.now() - started,
272
+ error: timedOut ? 'command timed out after ' + budget + ' ms' : undefined,
273
+ })
274
+ }
275
+ const timer = setTimeout(() => {
276
+ timedOut = true
277
+ try { stream.signal('KILL') } catch { /* channel gone */ }
278
+ try { stream.close() } catch { /* channel gone */ }
279
+ // Hard deadline: settle now even if the peer never acks the
280
+ // channel close (the stream 'close' handler is then a no-op).
281
+ finish()
282
+ }, budget)
283
+ stream.on('data', (chunk: Buffer) => appendOutput(stdout, chunk, engine.opts.maxOutputBytes))
284
+ stream.stderr.on('data', (chunk: Buffer) => appendOutput(stderr, chunk, engine.opts.maxOutputBytes))
285
+ stream.on('close', (code: number | null) => {
286
+ if (settled) return
287
+ settled = true
288
+ clearTimeout(timer)
289
+ resolve({
290
+ success: code === 0 && !timedOut,
291
+ exitCode: code,
292
+ timedOut,
293
+ stdout: stdout.text,
294
+ stderr: stderr.text,
295
+ durationMs: Date.now() - started,
296
+ })
297
+ })
298
+ stream.on('error', (streamError: Error) => {
299
+ if (settled) return
300
+ settled = true
301
+ clearTimeout(timer)
302
+ reject(streamError)
303
+ })
304
+ })
305
+ })
306
+ })
307
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * PTY shell sessions for the web terminal: a standalone (non-pooled)
3
+ * ssh2 connection with a long-lived shell channel, resize, and pausable
4
+ * output delivery.
5
+ */
6
+
7
+ import { Client } from 'ssh2'
8
+ import { connectChain, type PoolEngine } from './connection-pool.ts'
9
+
10
+ /** A live PTY shell session. */
11
+ export interface ShellSession {
12
+ /** Assign to receive remote output. */
13
+ onData?: (data: Buffer) => void
14
+ /** Assign to be notified when the channel closes. */
15
+ onExit?: (code: number | null, error?: string) => void
16
+ /** Write raw input to the shell. */
17
+ send(data: string): void
18
+ /** Resize the remote PTY. */
19
+ resize(cols: number, rows: number): void
20
+ /** Close the session and its channel. */
21
+ close(): void
22
+ /** Pause remote output delivery (transport backpressure). */
23
+ pause(): void
24
+ /** Resume remote output delivery. */
25
+ resume(): void
26
+ }
27
+
28
+ /**
29
+ * Open a PTY shell session for the web terminal (standalone connection).
30
+ * The shell is a long-lived exclusive stream: it uses its own connection so
31
+ * closing it can never tear down a pooled exec/tunnel sharing the alias.
32
+ */
33
+ export async function openShell(engine: PoolEngine, alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
34
+ const entry = engine.store.find(alias)
35
+ if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
36
+ const { client, hops } = await connectChain(engine, entry)
37
+ return await new Promise<ShellSession>((resolve, reject) => {
38
+ client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
39
+ if (error !== undefined) {
40
+ try { client.end() } catch { /* closed */ }
41
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
42
+ reject(error)
43
+ return
44
+ }
45
+ let tornDown = false
46
+ const teardown = (): void => {
47
+ if (tornDown) return
48
+ tornDown = true
49
+ try { client.end() } catch { /* closed */ }
50
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
51
+ }
52
+ const session: ShellSession = {
53
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
54
+ resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
55
+ close: () => {
56
+ try { stream.close() } catch { /* channel gone */ }
57
+ teardown()
58
+ },
59
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
60
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
61
+ }
62
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
63
+ stream.on('close', (code: number | null) => {
64
+ teardown()
65
+ session.onExit?.(code)
66
+ })
67
+ stream.on('error', (streamError: Error) => {
68
+ teardown()
69
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
70
+ })
71
+ resolve(session)
72
+ })
73
+ })
74
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * SFTP transfers: upload (file or recursive tree), single-file download, and
3
+ * remote directory listing. Every channel is opened once per operation and
4
+ * released exactly once so sshd's MaxSessions cap is never exhausted.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
8
+ import { dirname, join, relative, resolve as resolvePath } from 'node:path'
9
+ import { Client, type SFTPWrapper } from 'ssh2'
10
+ import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
11
+ import { withClient, type PoolEngine } from './connection-pool.ts'
12
+
13
+ /** Walk a local directory, collecting relative paths of every file. */
14
+ export function walkLocalDir(root: string): string[] {
15
+ const files: string[] = []
16
+ const visit = (dir: string): void => {
17
+ for (const name of readdirSync(dir)) {
18
+ const full = join(dir, name)
19
+ const stat = statSync(full)
20
+ if (stat.isDirectory()) visit(full)
21
+ else if (stat.isFile()) files.push(relative(root, full))
22
+ }
23
+ }
24
+ visit(root)
25
+ return files
26
+ }
27
+
28
+ /** Upload one local file (or directory tree) to a remote path. */
29
+ export async function upload(
30
+ engine: PoolEngine,
31
+ alias: string,
32
+ localPath: string,
33
+ remotePath: string,
34
+ recursive: boolean,
35
+ onProgress?: (progress: TransferProgress) => void,
36
+ ): Promise<{ bytes: number; files: number }> {
37
+ // Remote paths must be absolute: the mkdir chain and fastPut must agree
38
+ // on one resolution (relative paths previously created dirs at the root).
39
+ if (!remotePath.startsWith('/')) {
40
+ throw new Error('remotePath must be an absolute path (got \'' + remotePath + '\')')
41
+ }
42
+ const local = resolvePath(localPath)
43
+ if (!existsSync(local)) throw new Error('local path not found: \'' + localPath + '\'')
44
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
45
+ const stat = statSync(local)
46
+ let files: string[]
47
+ if (stat.isDirectory()) {
48
+ if (!recursive) throw new Error('\'' + localPath + '\' is a directory — enable recursive upload')
49
+ files = walkLocalDir(local)
50
+ await ensureRemoteDir(sftp, remotePath)
51
+ } else {
52
+ files = ['']
53
+ await ensureRemoteDir(sftp, dirname(remotePath))
54
+ }
55
+ let bytes = 0
56
+ for (const rel of files) {
57
+ const src = rel === '' ? local : join(local, rel)
58
+ // Remote paths always use forward slashes; normalize any OS separators.
59
+ const remoteRel = rel.split(/[\\/]/).join('/')
60
+ const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
61
+ await fastPut(sftp, src, dst, engine.opts.sftpConcurrency, onProgress)
62
+ bytes += statSync(src).size
63
+ }
64
+ return { bytes, files: files.length }
65
+ }))
66
+ }
67
+
68
+ /** Download one remote file to a local path. */
69
+ export async function download(
70
+ engine: PoolEngine,
71
+ alias: string,
72
+ remotePath: string,
73
+ localPath: string,
74
+ onProgress?: (progress: TransferProgress) => void,
75
+ ): Promise<{ bytes: number }> {
76
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
77
+ const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
78
+ sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
79
+ })
80
+ if (stat.isDirectory()) {
81
+ throw new Error('\'' + remotePath + '\' is a directory — directory download is not supported yet (download individual files)')
82
+ }
83
+ const local = resolvePath(localPath)
84
+ if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
85
+ await fastGet(sftp, remotePath, local, engine.opts.sftpConcurrency, onProgress)
86
+ return { bytes: statSync(local).size }
87
+ }))
88
+ }
89
+
90
+ /** List a remote directory (file browser). */
91
+ export async function ls(engine: PoolEngine, alias: string, path: string): Promise<RemoteDirEntry[]> {
92
+ return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
93
+ return await new Promise((resolve, reject) => {
94
+ sftp.readdir(path, (error, list) => {
95
+ if (error !== undefined) {
96
+ reject(error)
97
+ return
98
+ }
99
+ const entries: RemoteDirEntry[] = list.map(item => ({
100
+ name: item.filename,
101
+ type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
102
+ size: item.attrs.size,
103
+ mtimeMs: item.attrs.mtime * 1000,
104
+ mode: item.attrs.mode,
105
+ }))
106
+ resolve(entries)
107
+ })
108
+ })
109
+ }))
110
+ }
111
+
112
+ /**
113
+ * Open one SFTP channel, run the operation, and release the channel exactly
114
+ * once when the operation settles (success or error). ssh2 keeps each
115
+ * subsystem channel open until end(); without this, every transfer leaks a
116
+ * channel until sshd's MaxSessions cap makes all later opens fail.
117
+ */
118
+ async function withSftp<T>(client: Client, run: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
119
+ const sftp = await sftpChannel(client)
120
+ let ended = false
121
+ const endOnce = (): void => {
122
+ if (ended) return
123
+ ended = true
124
+ try { sftp.end() } catch { /* channel already closed */ }
125
+ }
126
+ // The channel can also close underneath us (peer reset, timeout); the
127
+ // guard makes the finally below a no-op instead of ending it twice.
128
+ sftp.once('close', endOnce)
129
+ try {
130
+ return await run(sftp)
131
+ } finally {
132
+ endOnce()
133
+ }
134
+ }
135
+
136
+ function sftpChannel(client: Client): Promise<SFTPWrapper> {
137
+ return new Promise((resolve, reject) => {
138
+ client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
139
+ })
140
+ }
141
+
142
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
143
+ function ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
144
+ return new Promise((resolve, reject) => {
145
+ const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
146
+ const walk = (index: number): void => {
147
+ if (index >= segments.length) {
148
+ resolve()
149
+ return
150
+ }
151
+ const current = '/' + segments.slice(0, index + 1).join('/')
152
+ sftp.stat(current, (statError) => {
153
+ if (statError === undefined) {
154
+ walk(index + 1)
155
+ return
156
+ }
157
+ // Statting a missing path fails; mkdir it (idempotent because the
158
+ // stat check runs first — some sftp servers throw on EEXIST).
159
+ sftp.mkdir(current, (mkdirError) => {
160
+ if (mkdirError !== undefined) {
161
+ reject(mkdirError)
162
+ return
163
+ }
164
+ walk(index + 1)
165
+ })
166
+ })
167
+ }
168
+ walk(0)
169
+ })
170
+ }
171
+
172
+ function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
173
+ return new Promise((resolve, reject) => {
174
+ let last = 0
175
+ let lastEmit = 0
176
+ const started = Date.now()
177
+ onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
178
+ sftp.fastPut(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
179
+ const now = Date.now()
180
+ // Throttle: high-speed links fire one callback per chunk; the UI only
181
+ // needs ~10 frames per second.
182
+ if (now - lastEmit < 100 && transferred < total) return
183
+ lastEmit = now
184
+ const elapsed = (now - started) / 1000
185
+ onProgress?.({
186
+ phase: 'transferring',
187
+ file: dst,
188
+ transferred,
189
+ total,
190
+ percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
191
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
192
+ })
193
+ last = transferred
194
+ } }, (error) => {
195
+ if (error !== undefined) {
196
+ onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
197
+ reject(error)
198
+ } else {
199
+ onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
200
+ resolve()
201
+ }
202
+ })
203
+ })
204
+ }
205
+
206
+ function fastGet(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
207
+ return new Promise((resolve, reject) => {
208
+ let last = 0
209
+ let lastEmit = 0
210
+ const started = Date.now()
211
+ sftp.fastGet(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
212
+ const now = Date.now()
213
+ if (now - lastEmit < 100 && transferred < total) return
214
+ lastEmit = now
215
+ const elapsed = (now - started) / 1000
216
+ onProgress?.({
217
+ phase: 'transferring',
218
+ file: src,
219
+ transferred,
220
+ total,
221
+ percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
222
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
223
+ })
224
+ last = transferred
225
+ } }, (error) => {
226
+ if (error !== undefined) {
227
+ onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
228
+ reject(error)
229
+ } else {
230
+ onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
231
+ resolve()
232
+ }
233
+ })
234
+ })
235
+ }