@linxin666/dsh-ssh 0.1.14 → 0.1.16

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,318 @@
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
+ export function connectClient(config: ConnectConfig): Promise<Client> {
92
+ return new Promise((resolve, reject) => {
93
+ const client = new Client()
94
+ let settled = false
95
+ const fail = (error: unknown): void => {
96
+ if (settled) return
97
+ settled = true
98
+ try { client.destroy() } catch { /* already closed */ }
99
+ reject(error instanceof Error ? error : new Error(String(error)))
100
+ }
101
+ client.once('ready', () => {
102
+ if (settled) return
103
+ settled = true
104
+ resolve(client)
105
+ })
106
+ // Keep an error listener attached after the handshake: when TCP connects
107
+ // but the handshake drops, ssh2 can emit a second 'error' after a single
108
+ // once-listener is gone, which would surface as an unhandled 'error' event.
109
+ // The settled guard turns later emissions into a no-op; other callers
110
+ // (like doAcquire) attach their own error listeners and all fire together.
111
+ client.on('error', fail)
112
+ try {
113
+ client.connect(config)
114
+ } catch (error) {
115
+ fail(error)
116
+ }
117
+ })
118
+ }
119
+
120
+ /** Cap captured output at the configured byte budget (marks truncation). */
121
+ export function appendOutput(target: { text: string; truncated: boolean }, chunk: Buffer, maxBytes: number): void {
122
+ if (target.truncated) return
123
+ if (target.text.length + chunk.length > maxBytes) {
124
+ let cut = chunk.toString('utf8').slice(0, maxBytes - target.text.length)
125
+ // Never split a surrogate pair at the cut boundary.
126
+ if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
127
+ target.text += cut + '…[output truncated]'
128
+ target.truncated = true
129
+ return
130
+ }
131
+ target.text += chunk.toString('utf8')
132
+ }
133
+
134
+ /**
135
+ * Build one full jump chain for an entry: hop clients connected through in
136
+ * order, each forwarding a stream to the next destination, ending with the
137
+ * target client. Shared by the pool and standalone shell sessions.
138
+ */
139
+ export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
140
+ const hops: Client[] = []
141
+ let sock: ConnectConfig['sock']
142
+ const chain = entry.proxyJump
143
+ for (let index = 0; index < chain.length; index += 1) {
144
+ const hopAlias = chain[index]
145
+ const hop = engine.store.find(hopAlias)
146
+ if (hop === undefined) {
147
+ for (const client of hops) client.end()
148
+ throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
149
+ }
150
+ const hopClient = await connectClient(buildConnectConfig(hop, sock))
151
+ hops.push(hopClient)
152
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
153
+ const nextHost = next !== undefined ? next.host : entry.host
154
+ const nextPort = next !== undefined ? next.port : entry.port
155
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
156
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
157
+ if (error !== undefined) {
158
+ for (const client of hops) client.end()
159
+ reject(error)
160
+ } else {
161
+ resolve(stream)
162
+ }
163
+ })
164
+ })
165
+ }
166
+ let target: Client | undefined
167
+ try {
168
+ target = await connectClient(buildConnectConfig(entry, sock))
169
+ return { client: target, hops }
170
+ } catch (error) {
171
+ for (const client of hops) client.end()
172
+ // connectClient already destroys the failed target on its own failure
173
+ // path; destroy defensively only when a reference leaked through, and
174
+ // guard it so a second destroy is never an issue.
175
+ if (target !== undefined) {
176
+ try { target.destroy() } catch { /* already destroyed */ }
177
+ }
178
+ throw error
179
+ }
180
+ }
181
+
182
+ /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
183
+ export async function acquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
184
+ const pending = engine.acquireQueue.get(alias)
185
+ if (pending !== undefined) return pending
186
+ const task = doAcquire(engine, alias)
187
+ engine.acquireQueue.set(alias, task)
188
+ try {
189
+ return await task
190
+ } finally {
191
+ if (engine.acquireQueue.get(alias) === task) engine.acquireQueue.delete(alias)
192
+ }
193
+ }
194
+
195
+ async function doAcquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
196
+ const entry = engine.store.find(alias)
197
+ if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
198
+ const { client, hops } = await connectChain(engine, entry)
199
+ const record: PoolRecord = { client, hops, idleAt: Date.now(), pinned: false, broken: false, inFlight: 0 }
200
+ client.on('error', () => { record.broken = true })
201
+ client.on('close', () => { record.broken = true })
202
+ engine.pool.set(alias, record)
203
+ return record
204
+ }
205
+
206
+ /**
207
+ * Tear down one alias's record. When `record` is given and no longer the
208
+ * pooled record for the alias (a concurrent acquire replaced it), nothing
209
+ * is torn down — the connection belongs to someone else now.
210
+ */
211
+ export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void {
212
+ const current = engine.pool.get(alias)
213
+ if (record !== undefined && current !== record) return
214
+ if (current === undefined) return
215
+ engine.pool.delete(alias)
216
+ try { current.client.end() } catch { /* already closed */ }
217
+ for (const hop of current.hops) {
218
+ try { hop.end() } catch { /* already closed */ }
219
+ }
220
+ }
221
+
222
+ /** Close connections idle beyond the threshold (skips pinned and in-flight). */
223
+ export function sweepPool(engine: PoolEngine): void {
224
+ const cutoff = Date.now() - engine.opts.idleTimeoutMs
225
+ for (const [alias, record] of engine.pool) {
226
+ if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) {
227
+ disposeRecord(engine, alias, record)
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Run `fn` with a live client for `alias`, reconnecting (up to the
234
+ * attempt budget) when the connection broke mid-flight.
235
+ */
236
+ export async function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
237
+ let lastError: unknown
238
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
239
+ let record = engine.pool.get(alias)
240
+ if (record === undefined || record.broken) {
241
+ if (record !== undefined) disposeRecord(engine, alias, record)
242
+ record = await acquire(engine, alias)
243
+ }
244
+ record.idleAt = Date.now()
245
+ record.inFlight += 1
246
+ try {
247
+ const result = await fn(record.client)
248
+ record.idleAt = Date.now()
249
+ return result
250
+ } finally {
251
+ record.inFlight -= 1
252
+ }
253
+ }
254
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
255
+ }
256
+
257
+ /** Run one command on `alias` (reusing the pooled connection). */
258
+ export async function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
259
+ const started = Date.now()
260
+ const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs
261
+ return withClient(engine, alias, async (client) => {
262
+ return await new Promise<ExecResult>((resolve, reject) => {
263
+ client.exec(command, (error, stream) => {
264
+ if (error !== undefined) {
265
+ reject(error)
266
+ return
267
+ }
268
+ const stdout = { text: '', truncated: false }
269
+ const stderr = { text: '', truncated: false }
270
+ let timedOut = false
271
+ let settled = false
272
+ const finish = (): void => {
273
+ if (settled) return
274
+ settled = true
275
+ clearTimeout(timer)
276
+ resolve({
277
+ success: false,
278
+ exitCode: null,
279
+ timedOut,
280
+ stdout: stdout.text,
281
+ stderr: stderr.text,
282
+ durationMs: Date.now() - started,
283
+ error: timedOut ? 'command timed out after ' + budget + ' ms' : undefined,
284
+ })
285
+ }
286
+ const timer = setTimeout(() => {
287
+ timedOut = true
288
+ try { stream.signal('KILL') } catch { /* channel gone */ }
289
+ try { stream.close() } catch { /* channel gone */ }
290
+ // Hard deadline: settle now even if the peer never acks the
291
+ // channel close (the stream 'close' handler is then a no-op).
292
+ finish()
293
+ }, budget)
294
+ stream.on('data', (chunk: Buffer) => appendOutput(stdout, chunk, engine.opts.maxOutputBytes))
295
+ stream.stderr.on('data', (chunk: Buffer) => appendOutput(stderr, chunk, engine.opts.maxOutputBytes))
296
+ stream.on('close', (code: number | null) => {
297
+ if (settled) return
298
+ settled = true
299
+ clearTimeout(timer)
300
+ resolve({
301
+ success: code === 0 && !timedOut,
302
+ exitCode: code,
303
+ timedOut,
304
+ stdout: stdout.text,
305
+ stderr: stderr.text,
306
+ durationMs: Date.now() - started,
307
+ })
308
+ })
309
+ stream.on('error', (streamError: Error) => {
310
+ if (settled) return
311
+ settled = true
312
+ clearTimeout(timer)
313
+ reject(streamError)
314
+ })
315
+ })
316
+ })
317
+ })
318
+ }
@@ -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
+ }