UNPKG

pure-js-sftp

Version:

A pure JavaScript SFTP client with revolutionary RSA-SHA2 compatibility fixes. Zero native dependencies, built on ssh2-streams with 100% SSH key support.

937 lines 39.5 kB
"use strict"; /** * SFTP Client using ssh2-streams transport */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SSH2StreamsSFTPClient = void 0; const events_1 = require("events"); const ssh2_streams_transport_1 = require("../ssh/ssh2-streams-transport"); const types_1 = require("../ssh/types"); class SSH2StreamsSFTPClient extends events_1.EventEmitter { constructor(options) { super(); this.sftpChannel = null; this.requestId = 0; this.ready = false; this.pendingRequests = new Map(); this.dataBuffer = Buffer.alloc(0); // Operation limit tracking for automatic reconnection this.operationCount = 0; this.bytesTransferred = 0; this.detectedOperationLimit = null; this.detectedDataLimit = null; this.lastLimitDetectionTime = 0; // Keepalive and health check state this.keepaliveTimer = null; this.healthCheckTimer = null; this.missedKeepalives = 0; this.isHealthy = true; this.reconnectAttempts = 0; this.reconnecting = false; this.lastReconnectError = null; this.validateConfig(options); this.config = options; this.transport = new ssh2_streams_transport_1.SSH2StreamsTransport(options); this.setupTransportHandlers(); // Store original connect method for auto-reconnect this.originalConnect = this.connect.bind(this); } /** * Validate configuration options */ validateConfig(options) { // Validate keepalive configuration if (options.keepalive?.enabled) { if (options.keepalive.interval !== undefined && options.keepalive.interval < 1000) { throw new Error('Keepalive interval must be >= 1000ms'); } if (options.keepalive.maxMissed !== undefined && options.keepalive.maxMissed < 1) { throw new Error('Keepalive maxMissed must be >= 1'); } } // Validate health check configuration if (options.healthCheck?.enabled) { if (options.healthCheck.interval !== undefined && options.healthCheck.interval < 1000) { throw new Error('Health check interval must be >= 1000ms'); } if (options.healthCheck.method && !['ping', 'realpath'].includes(options.healthCheck.method)) { throw new Error('Health check method must be "ping" or "realpath"'); } } // Validate auto-reconnect configuration if (options.autoReconnect?.enabled) { if (options.autoReconnect.maxAttempts !== undefined && options.autoReconnect.maxAttempts < 1) { throw new Error('Auto-reconnect maxAttempts must be >= 1'); } if (options.autoReconnect.delay !== undefined && options.autoReconnect.delay < 100) { throw new Error('Auto-reconnect delay must be >= 100ms'); } if (options.autoReconnect.backoff !== undefined && options.autoReconnect.backoff < 1) { throw new Error('Auto-reconnect backoff must be >= 1'); } } } setupTransportHandlers() { this.transport.on('ready', () => { this.emit('debug', 'SSH transport ready'); }); this.transport.on('error', (err) => { this.emit('error', err); }); this.transport.on('close', () => { this.ready = false; this.isHealthy = false; this.stopKeepalive(); this.stopHealthCheck(); // Attempt auto-reconnect if enabled and not already reconnecting if (this.config.autoReconnect?.enabled && !this.reconnecting) { this.attemptReconnect(); } else { this.emit('close'); } }); this.transport.on('debug', (msg) => { this.emit('debug', msg); }); } /** * Connect and initialize SFTP */ async connect() { try { // Connect SSH transport await this.transport.connect(); // Open SFTP channel this.sftpChannel = await this.transport.openSFTP(); // Set up SFTP channel handlers BEFORE sending INIT this.setupSFTPHandlers(); // Small delay to ensure handlers are ready await new Promise(resolve => setTimeout(resolve, 100)); // Send SFTP INIT await this.initializeSFTP(); this.ready = true; this.reconnectAttempts = 0; // Reset reconnect attempts on successful connection this.resetOperationCounters(); // Reset operation limits tracking on new connection // Start keepalive and health checks this.startKeepalive(); this.startHealthCheck(); this.emit('ready'); } catch (error) { this.emit('error', error); // Attempt auto-reconnect if enabled if (this.config.autoReconnect?.enabled && !this.reconnecting) { this.attemptReconnect(); } throw error; } } setupSFTPHandlers() { if (!this.sftpChannel) return; this.sftpChannel.on('data', (data) => { this.emit('debug', `SFTP channel received data: ${data.length} bytes`); this.handleSFTPData(data); }); this.sftpChannel.on('close', () => { this.ready = false; this.emit('close'); }); this.sftpChannel.on('error', (err) => { this.emit('error', err); }); } async initializeSFTP() { if (!this.sftpChannel) { throw new Error('SFTP channel not available'); } return new Promise((resolve, reject) => { const initTimeout = this.config?.connectTimeout ?? 30000; const timeout = setTimeout(() => { reject(new Error(`SFTP initialization timeout after ${initTimeout}ms`)); }, initTimeout); // Listen for SFTP VERSION response const handleVersion = (packet) => { if (packet.type === types_1.SFTP_MSG.VERSION) { clearTimeout(timeout); const version = packet.payload.readUInt32BE(0); if (version === types_1.SFTP_VERSION) { this.removeListener('sftpPacket', handleVersion); resolve(); } else { this.removeListener('sftpPacket', handleVersion); reject(new types_1.SFTPError(`Unsupported SFTP version: ${version}`)); } } }; this.on('sftpPacket', handleVersion); // Send SFTP INIT packet const initPacket = Buffer.from([ 0, 0, 0, 5, // Length: 5 bytes types_1.SFTP_MSG.INIT, // SSH_FXP_INIT 0, 0, 0, types_1.SFTP_VERSION // Version: 3 ]); this.sftpChannel.write(initPacket); }); } handleSFTPData(data) { // Append new data to buffer this.dataBuffer = Buffer.concat([this.dataBuffer, data]); let offset = 0; while (offset + 4 <= this.dataBuffer.length) { const length = this.dataBuffer.readUInt32BE(offset); // Check if we have the complete packet if (offset + 4 + length > this.dataBuffer.length) { break; // Wait for more data } const packetData = this.dataBuffer.subarray(offset + 4, offset + 4 + length); const type = packetData.readUInt8(0); let id; let payload = packetData.subarray(1); if (type !== types_1.SFTP_MSG.INIT && type !== types_1.SFTP_MSG.VERSION) { id = payload.readUInt32BE(0); payload = payload.subarray(4); } const packet = { type, id, payload }; this.handleSFTPPacket(packet); offset += 4 + length; } // Remove processed data from buffer if (offset > 0) { this.dataBuffer = this.dataBuffer.subarray(offset); } } handleSFTPPacket(packet) { this.emit('debug', `SFTP packet received: type=${packet.type}, id=${packet.id}`); this.emit('sftpPacket', packet); if (packet.id !== undefined) { const request = this.pendingRequests.get(packet.id); if (request) { this.pendingRequests.delete(packet.id); // Clear timeout if it exists if (request.timeout) { clearTimeout(request.timeout); } switch (packet.type) { case types_1.SFTP_MSG.STATUS: this.handleStatusPacket(packet, request); break; case types_1.SFTP_MSG.HANDLE: request.resolve(packet.payload.subarray(4)); // Skip length prefix break; case types_1.SFTP_MSG.DATA: { const dataLength = packet.payload.readUInt32BE(0); const data = packet.payload.subarray(4, 4 + dataLength); request.resolve(data); break; } case types_1.SFTP_MSG.NAME: { const entries = this.parseNamePacket(packet.payload); request.resolve(entries); break; } case types_1.SFTP_MSG.ATTRS: { const { attrs } = this.parseFileAttributes(packet.payload, 0); request.resolve(attrs); break; } } } } } handleStatusPacket(packet, request) { const code = packet.payload.readUInt32BE(0); if (code === types_1.SFTP_STATUS.OK) { request.resolve(); } else { let message = 'Unknown error'; if (packet.payload.length > 4) { const messageLength = packet.payload.readUInt32BE(4); message = packet.payload.subarray(8, 8 + messageLength).toString('utf8'); } request.reject(new types_1.SFTPError(message, code)); } } parseNamePacket(payload) { const count = payload.readUInt32BE(0); const entries = []; let offset = 4; for (let i = 0; i < count; i++) { const filenameLength = payload.readUInt32BE(offset); offset += 4; const filename = payload.subarray(offset, offset + filenameLength).toString('utf8'); offset += filenameLength; const longnameLength = payload.readUInt32BE(offset); offset += 4; const longname = payload.subarray(offset, offset + longnameLength).toString('utf8'); offset += longnameLength; const { attrs, bytesRead } = this.parseFileAttributes(payload, offset); offset += bytesRead; entries.push({ filename, longname, attrs }); } return entries; } parseFileAttributes(buffer, offset) { const flags = buffer.readUInt32BE(offset); const attrs = { flags }; let bytesRead = 4; if (flags & types_1.SFTP_ATTR.SIZE) { // Read 64-bit size as two 32-bit values for better compatibility const sizeHigh = buffer.readUInt32BE(offset + bytesRead); const sizeLow = buffer.readUInt32BE(offset + bytesRead + 4); attrs.size = sizeHigh * 0x100000000 + sizeLow; bytesRead += 8; } if (flags & types_1.SFTP_ATTR.UIDGID) { attrs.uid = buffer.readUInt32BE(offset + bytesRead); attrs.gid = buffer.readUInt32BE(offset + bytesRead + 4); bytesRead += 8; } if (flags & types_1.SFTP_ATTR.PERMISSIONS) { attrs.permissions = buffer.readUInt32BE(offset + bytesRead); bytesRead += 4; } if (flags & types_1.SFTP_ATTR.ACMODTIME) { attrs.atime = buffer.readUInt32BE(offset + bytesRead); attrs.mtime = buffer.readUInt32BE(offset + bytesRead + 4); bytesRead += 8; } // Add helper methods for file type detection (always present for ssh2-sftp-client compatibility) if (attrs.permissions !== undefined) { const mode = attrs.permissions; attrs.isFile = () => (mode & 0o170000) === 0o100000; // S_IFREG attrs.isDirectory = () => (mode & 0o170000) === 0o040000; // S_IFDIR attrs.isSymbolicLink = () => (mode & 0o170000) === 0o120000; // S_IFLNK attrs.isBlockDevice = () => (mode & 0o170000) === 0o060000; // S_IFBLK attrs.isCharacterDevice = () => (mode & 0o170000) === 0o020000; // S_IFCHR attrs.isFIFO = () => (mode & 0o170000) === 0o010000; // S_IFIFO attrs.isSocket = () => (mode & 0o170000) === 0o140000; // S_IFSOCK } else { // Fallback when permissions not available - always provide functions attrs.isFile = () => false; attrs.isDirectory = () => false; attrs.isSymbolicLink = () => false; attrs.isBlockDevice = () => false; attrs.isCharacterDevice = () => false; attrs.isFIFO = () => false; attrs.isSocket = () => false; } return { attrs: attrs, bytesRead }; } sendSFTPRequest(type, payload, expectResponse = true, timeoutMs) { if (!this.sftpChannel) { throw new Error('SFTP channel not available'); } const actualTimeout = timeoutMs ?? this.config?.operationTimeout ?? 30000; return new Promise((resolve, reject) => { const id = ++this.requestId; if (expectResponse) { const request = { resolve, reject, type: types_1.SFTP_MSG[type] }; this.pendingRequests.set(id, request); // Add timeout for operations that might hang const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`SFTP request timeout after ${actualTimeout}ms for ${types_1.SFTP_MSG[type]} (id: ${id})`)); }, actualTimeout); // Store timeout with request for cleanup request.timeout = timeout; } try { // Build SFTP packet const idBuffer = Buffer.allocUnsafe(4); idBuffer.writeUInt32BE(id, 0); const packetPayload = Buffer.concat([Buffer.from([type]), idBuffer, payload]); const lengthBuffer = Buffer.allocUnsafe(4); lengthBuffer.writeUInt32BE(packetPayload.length, 0); const packet = Buffer.concat([lengthBuffer, packetPayload]); // Write packet to SFTP channel this.sftpChannel.write(packet); if (!expectResponse) { resolve(undefined); } } catch (error) { if (expectResponse) { const request = this.pendingRequests.get(id); if (request && request.timeout) { clearTimeout(request.timeout); } this.pendingRequests.delete(id); } reject(error); } }); } /** * Open a file */ async openFile(path, flags = types_1.SFTP_OPEN_FLAGS.READ) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const flagsBuffer = Buffer.allocUnsafe(4); flagsBuffer.writeUInt32BE(flags, 0); const attrsBuffer = Buffer.allocUnsafe(4); attrsBuffer.writeUInt32BE(0, 0); // No attributes const payload = Buffer.concat([pathLength, pathBuffer, flagsBuffer, attrsBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.OPEN, payload); } /** * Sync/flush a file to disk (forces write) */ async syncFile(handle) { try { // Try to use fsync@openssh.com extension first const extensionName = Buffer.from('fsync@openssh.com', 'utf8'); const extensionNameLength = Buffer.allocUnsafe(4); extensionNameLength.writeUInt32BE(extensionName.length, 0); const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const payload = Buffer.concat([extensionNameLength, extensionName, handleLength, handle]); await this.sendSFTPRequest(types_1.SFTP_MSG.EXTENDED, payload); this.emit('debug', 'File synced using fsync@openssh.com extension'); } catch (error) { // If fsync extension fails, fall back to a small delay this.emit('debug', 'fsync extension not supported, using delay fallback'); await new Promise(resolve => setTimeout(resolve, 200)); } } /** * Close a file */ async closeFile(handle) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const payload = Buffer.concat([handleLength, handle]); // Use adaptive timeout for control operations const controlTimeout = this.transport.getAdaptiveTimeout('control'); return this.sendSFTPRequest(types_1.SFTP_MSG.CLOSE, payload, true, controlTimeout); } /** * Read file data */ async readFile(handle, offset, length, timeoutMs) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); // Use consistent 32-bit offset handling const offsetHigh = Buffer.allocUnsafe(4); const offsetLow = Buffer.allocUnsafe(4); offsetHigh.writeUInt32BE(Math.floor(offset / 0x100000000), 0); offsetLow.writeUInt32BE(offset & 0xFFFFFFFF, 0); const lengthBuffer = Buffer.allocUnsafe(4); lengthBuffer.writeUInt32BE(length, 0); const payload = Buffer.concat([handleLength, handle, offsetHigh, offsetLow, lengthBuffer]); // Add debug info for read operations const totalPacketSize = 4 + 1 + 4 + 4 + handle.length + 8 + 4; this.emit('debug', `SFTP READ: offset=${offset}, requestSize=${length}, handleSize=${handle.length}, totalPacketSize=${totalPacketSize}`); // Track operation before sending request this.trackOperation(0, 'READ'); // Will track bytes when response arrives try { const result = await this.sendSFTPRequest(types_1.SFTP_MSG.READ, payload, true, timeoutMs); // Track actual bytes received if (result && result.length > 0) { this.bytesTransferred += result.length; // Update byte count with actual data received this.emit('debug', `READ completed: received ${result.length} bytes (total: ${(this.bytesTransferred / (1024 * 1024)).toFixed(2)}MB)`); } return result; } catch (error) { // If this was a timeout, record it as a potential server limit if (error instanceof Error && error.message.includes('timeout')) { this.recordServerLimit(this.operationCount, this.bytesTransferred); } throw error; } } /** * List directory contents */ async listDirectory(path) { const handle = await this.openDirectory(path); const entries = []; try { while (true) { try { const batch = await this.readDirectory(handle); entries.push(...batch); } catch (error) { if (error instanceof types_1.SFTPError && error.code === types_1.SFTP_STATUS.EOF) { break; } throw error; } } } finally { await this.closeFile(handle); } return entries; } async openDirectory(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.OPENDIR, payload); } async readDirectory(handle) { const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); const payload = Buffer.concat([handleLength, handle]); return this.sendSFTPRequest(types_1.SFTP_MSG.READDIR, payload); } /** * Get file stats */ async stat(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.STAT, payload); } /** * Write data to a file handle */ async writeFile(handle, offset, data, timeoutMs) { // Build SFTP WRITE packet according to RFC 4251/4252 const handleLength = Buffer.allocUnsafe(4); handleLength.writeUInt32BE(handle.length, 0); // Use 32-bit offset for better compatibility (most files are < 4GB) const offsetHigh = Buffer.allocUnsafe(4); const offsetLow = Buffer.allocUnsafe(4); offsetHigh.writeUInt32BE(Math.floor(offset / 0x100000000), 0); offsetLow.writeUInt32BE(offset & 0xFFFFFFFF, 0); const dataLength = Buffer.allocUnsafe(4); dataLength.writeUInt32BE(data.length, 0); const payload = Buffer.concat([handleLength, handle, offsetHigh, offsetLow, dataLength, data]); // Add debug info for write operations const totalPacketSize = 4 + 1 + 4 + 4 + handle.length + 8 + 4 + data.length; this.emit('debug', `SFTP WRITE: offset=${offset}, dataSize=${data.length}, handleSize=${handle.length}, totalPacketSize=${totalPacketSize}`); return this.sendSFTPRequest(types_1.SFTP_MSG.WRITE, payload, true, timeoutMs); } /** * Remove a file */ async removeFile(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.REMOVE, payload); } /** * Rename a file */ async renameFile(oldPath, newPath) { const oldPathBuffer = Buffer.from(oldPath, 'utf8'); const oldPathLength = Buffer.allocUnsafe(4); oldPathLength.writeUInt32BE(oldPathBuffer.length, 0); const newPathBuffer = Buffer.from(newPath, 'utf8'); const newPathLength = Buffer.allocUnsafe(4); newPathLength.writeUInt32BE(newPathBuffer.length, 0); const payload = Buffer.concat([oldPathLength, oldPathBuffer, newPathLength, newPathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.RENAME, payload); } /** * Create a directory */ async makeDirectory(path, attrs) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); // Simple attributes - just set directory flag const attrFlags = Buffer.allocUnsafe(4); attrFlags.writeUInt32BE(types_1.SFTP_ATTR.PERMISSIONS, 0); const permissions = Buffer.allocUnsafe(4); permissions.writeUInt32BE(attrs?.permissions || 0o755, 0); const payload = Buffer.concat([pathLength, pathBuffer, attrFlags, permissions]); return this.sendSFTPRequest(types_1.SFTP_MSG.MKDIR, payload); } /** * Remove a directory */ async removeDirectory(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); return this.sendSFTPRequest(types_1.SFTP_MSG.RMDIR, payload); } /** * Set file attributes */ async setAttributes(path, attrs) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); // Build attributes in correct RFC order: size, uid/gid, permissions, atime/mtime let flags = 0; let attrData = Buffer.alloc(0); // Order matters! Follow RFC: size, uid/gid, permissions, atime/mtime if (attrs.size !== undefined) { flags |= types_1.SFTP_ATTR.SIZE; const sizeHigh = Buffer.allocUnsafe(4); const sizeLow = Buffer.allocUnsafe(4); sizeHigh.writeUInt32BE(Math.floor(attrs.size / 0x100000000), 0); sizeLow.writeUInt32BE(attrs.size & 0xFFFFFFFF, 0); attrData = Buffer.concat([attrData, sizeHigh, sizeLow]); } if (attrs.uid !== undefined && attrs.gid !== undefined) { flags |= types_1.SFTP_ATTR.UIDGID; const uidBuffer = Buffer.allocUnsafe(4); const gidBuffer = Buffer.allocUnsafe(4); uidBuffer.writeUInt32BE(attrs.uid, 0); gidBuffer.writeUInt32BE(attrs.gid, 0); attrData = Buffer.concat([attrData, uidBuffer, gidBuffer]); } if (attrs.permissions !== undefined) { flags |= types_1.SFTP_ATTR.PERMISSIONS; const permBuffer = Buffer.allocUnsafe(4); permBuffer.writeUInt32BE(attrs.permissions, 0); attrData = Buffer.concat([attrData, permBuffer]); } if (attrs.atime !== undefined && attrs.mtime !== undefined) { flags |= types_1.SFTP_ATTR.ACMODTIME; const atimeBuffer = Buffer.allocUnsafe(4); const mtimeBuffer = Buffer.allocUnsafe(4); atimeBuffer.writeUInt32BE(attrs.atime, 0); mtimeBuffer.writeUInt32BE(attrs.mtime, 0); attrData = Buffer.concat([attrData, atimeBuffer, mtimeBuffer]); } const attrFlags = Buffer.allocUnsafe(4); attrFlags.writeUInt32BE(flags, 0); const payload = Buffer.concat([pathLength, pathBuffer, attrFlags, attrData]); return this.sendSFTPRequest(types_1.SFTP_MSG.SETSTAT, payload); } /** * Get real path (resolve symbolic links) */ async realPath(path) { const pathBuffer = Buffer.from(path, 'utf8'); const pathLength = Buffer.allocUnsafe(4); pathLength.writeUInt32BE(pathBuffer.length, 0); const payload = Buffer.concat([pathLength, pathBuffer]); // sendSFTPRequest for REALPATH returns DirectoryEntry[] from parseNamePacket const entries = await this.sendSFTPRequest(types_1.SFTP_MSG.REALPATH, payload); if (entries && entries.length === 1) { return entries[0].filename; // Return the resolved absolute path } throw new Error('Invalid realpath response: expected exactly one path entry'); } /** * Disconnect */ disconnect() { this.ready = false; this.isHealthy = false; this.reconnecting = false; this.reconnectAttempts = 0; this.lastReconnectError = null; this.stopKeepalive(); this.stopHealthCheck(); this.cleanupPendingRequests(); if (this.sftpChannel) { this.sftpChannel.end(); } this.transport.disconnect(); } /** * Start SSH keepalive timer */ startKeepalive() { if (!this.config.keepalive?.enabled) return; const interval = this.config.keepalive.interval ?? 30000; this.keepaliveTimer = setInterval(() => { this.sendKeepalive(); }, interval); this.emit('debug', `Keepalive started with ${interval}ms interval`); } /** * Stop SSH keepalive timer */ stopKeepalive() { if (this.keepaliveTimer) { clearInterval(this.keepaliveTimer); this.keepaliveTimer = null; this.missedKeepalives = 0; this.emit('debug', 'Keepalive stopped'); } } /** * Send SSH keepalive (ping) */ async sendKeepalive() { if (!this.ready || !this.transport.isConnected()) { return; } try { // Use SSH ping functionality from transport layer await this.transport.ping(); this.missedKeepalives = 0; this.emit('keepalive', { type: 'ping', success: true }); } catch (err) { this.missedKeepalives++; const maxMissed = this.config.keepalive?.maxMissed ?? 3; this.emit('keepalive', { type: 'ping', success: false, missed: this.missedKeepalives }); this.emit('debug', `Keepalive failed: ${this.missedKeepalives}/${maxMissed}`); if (this.missedKeepalives >= maxMissed) { this.emit('debug', 'Max keepalive failures reached, disconnecting'); this.isHealthy = false; this.disconnect(); } } } /** * Start health check timer */ startHealthCheck() { if (!this.config.healthCheck?.enabled) return; const interval = this.config.healthCheck.interval ?? 60000; this.healthCheckTimer = setInterval(() => { this.performHealthCheck(); }, interval); this.emit('debug', `Health check started with ${interval}ms interval`); } /** * Stop health check timer */ stopHealthCheck() { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; this.emit('debug', 'Health check stopped'); } } /** * Perform connection health check */ async performHealthCheck() { if (!this.ready || !this.transport.isConnected()) { this.isHealthy = false; return; } try { const method = this.config.healthCheck?.method ?? 'realpath'; if (method === 'ping') { await this.transport.ping(); } else if (method === 'realpath') { await this.realPath('.'); } const wasUnhealthy = !this.isHealthy; this.isHealthy = true; this.emit('healthCheck', { type: method, success: true, healthy: true }); if (wasUnhealthy) { this.emit('debug', 'Connection health restored'); } } catch (error) { this.isHealthy = false; this.emit('healthCheck', { type: this.config.healthCheck?.method ?? 'realpath', success: false, healthy: false, error: error }); this.emit('debug', `Health check failed: ${error.message}`); // Attempt auto-reconnect if enabled if (this.config.autoReconnect?.enabled && !this.reconnecting) { this.attemptReconnect(); } } } /** * Attempt automatic reconnection with proper termination logic */ async attemptReconnect() { // Prevent concurrent reconnection attempts if (this.reconnecting) return; const maxAttempts = this.config.autoReconnect?.maxAttempts ?? 3; const initialDelay = this.config.autoReconnect?.delay ?? 1000; const backoff = this.config.autoReconnect?.backoff ?? 2; // Check if max attempts already reached BEFORE starting if (this.reconnectAttempts >= maxAttempts) { this.emit('debug', `Max reconnect attempts (${maxAttempts}) reached`); this.emit('reconnectFailed', { attempts: this.reconnectAttempts, maxAttempts, lastError: this.lastReconnectError?.message }); this.emit('close'); return; } this.reconnecting = true; this.reconnectAttempts++; const delay = initialDelay * Math.pow(backoff, this.reconnectAttempts - 1); this.emit('debug', `Attempting reconnect ${this.reconnectAttempts}/${maxAttempts} in ${delay}ms`); this.emit('reconnectAttempt', { attempt: this.reconnectAttempts, maxAttempts, delay }); setTimeout(async () => { try { // Clean up before reconnecting this.cleanupPendingRequests(); await this.originalConnect(); this.reconnecting = false; this.reconnectAttempts = 0; // Reset on successful reconnection this.lastReconnectError = null; this.emit('debug', `Reconnect successful after ${this.reconnectAttempts} attempts`); this.emit('reconnectSuccess', { attempts: this.reconnectAttempts }); } catch (error) { this.lastReconnectError = error; this.reconnecting = false; this.emit('debug', `Reconnect attempt ${this.reconnectAttempts} failed: ${error.message}`); this.emit('reconnectError', { attempt: this.reconnectAttempts, error: error }); // Only retry if we haven't reached max attempts if (this.reconnectAttempts < maxAttempts) { this.attemptReconnect(); } else { // Final failure - emit reconnectFailed this.emit('debug', `Max reconnect attempts (${maxAttempts}) reached after failure`); this.emit('reconnectFailed', { attempts: this.reconnectAttempts, maxAttempts, lastError: this.lastReconnectError?.message }); this.emit('close'); } } }, delay); } /** * Clean up pending requests on disconnect/reconnect */ cleanupPendingRequests() { for (const [id, request] of this.pendingRequests) { // Clear any associated timeouts if (request.timeout) { clearTimeout(request.timeout); } // Reject with connection error request.reject(new Error('Connection lost during operation')); } this.pendingRequests.clear(); } /** * Get connection health status */ getHealthStatus() { return { healthy: this.isHealthy, connected: this.transport.isConnected(), ready: this.ready }; } /** * Check if connected and ready */ isReady() { return this.ready && this.transport.isConnected(); } /** * Get current SSH channel window size for flow control */ getCurrentWindowSize() { return this.transport.getCurrentWindowSize(); } /** * Report transfer metrics for adaptive performance tuning */ reportTransferMetrics(speedMBps, hadTimeout, responseTimeMs) { this.transport.adaptPerformance(speedMBps, hadTimeout, responseTimeMs); } /** * Get transport reference for direct access to adaptive methods */ getTransport() { return this.transport; } /** * Calculate safe concurrency based on SSH window size and chunk size */ getSafeConcurrency(chunkSize, maxConcurrency = 64) { return this.transport.getSafeConcurrency(chunkSize, maxConcurrency); } /** * Get optimal concurrency based on window size and performance testing */ getOptimalConcurrency(chunkSize) { return this.transport.getOptimalConcurrency(chunkSize); } /** * Get maximum safe SFTP chunk size based on SSH packet limits */ getMaxSafeChunkSize() { return this.transport.getMaxSafeChunkSize(); } /** * Track operation for limit detection */ trackOperation(bytesTransferred = 0, operationType = 'READ') { this.operationCount++; this.bytesTransferred += bytesTransferred; // Emit debug info for operations that involve data transfer if (bytesTransferred > 0) { this.emit('debug', `Operation ${this.operationCount}: ${operationType} ${bytesTransferred} bytes (total: ${(this.bytesTransferred / (1024 * 1024)).toFixed(2)}MB)`); } } /** * Record server limit when timeout occurs */ recordServerLimit(operationCount, bytesTransferred) { this.detectedOperationLimit = operationCount; this.detectedDataLimit = bytesTransferred; this.lastLimitDetectionTime = Date.now(); const mbTransferred = (bytesTransferred / (1024 * 1024)).toFixed(2); this.emit('debug', `Server limit detected: ${operationCount} operations, ${mbTransferred}MB`); this.emit('serverLimitDetected', { operations: operationCount, bytes: bytesTransferred, mb: mbTransferred }); } /** * Check if we're approaching server limits */ isApproachingLimit() { // If we haven't detected limits yet, assume reasonable defaults const operationLimit = this.detectedOperationLimit ?? 75; // Conservative default const dataLimitMB = this.detectedDataLimit ? (this.detectedDataLimit / (1024 * 1024)) : 0.6; // 600KB default // Use 90% of detected limits as safety margin const safeOperationLimit = Math.floor(operationLimit * 0.9); const safeDataLimitBytes = Math.floor(dataLimitMB * 0.9 * 1024 * 1024); const approachingOperationLimit = this.operationCount >= safeOperationLimit; const approachingDataLimit = this.bytesTransferred >= safeDataLimitBytes; if (approachingOperationLimit || approachingDataLimit) { this.emit('debug', `Approaching limits: ops ${this.operationCount}/${safeOperationLimit}, data ${(this.bytesTransferred / (1024 * 1024)).toFixed(2)}MB/${(safeDataLimitBytes / (1024 * 1024)).toFixed(2)}MB`); return true; } return false; } /** * Reset operation counters (call after reconnection) */ resetOperationCounters() { this.operationCount = 0; this.bytesTransferred = 0; this.emit('debug', 'Operation counters reset after reconnection'); } /** * Get current operation statistics */ getOperationStats() { return { operations: this.operationCount, bytesTransferred: this.bytesTransferred, mbTransferred: this.bytesTransferred / (1024 * 1024), detectedLimits: { operations: this.detectedOperationLimit, bytes: this.detectedDataLimit } }; } } exports.SSH2StreamsSFTPClient = SSH2StreamsSFTPClient; //# sourceMappingURL=ssh2-streams-client.js.map