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.

1,049 lines (1,048 loc) 89.3 kB
"use strict"; /** * Pure JS SFTP Client * A pure JavaScript SFTP client with no native dependencies * 100% API compatible with ssh2-sftp-client */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SFTPError = exports.isPureJSSigningFixEnabled = exports.disablePureJSSigningFix = exports.enablePureJSSigningFix = exports.SSH2StreamsTransport = exports.SSH2StreamsSFTPClient = exports.SftpClient = void 0; const ssh2_streams_client_1 = require("./sftp/ssh2-streams-client"); const types_1 = require("./ssh/types"); const fsPromises = __importStar(require("fs/promises")); const path = __importStar(require("path")); const events_1 = require("events"); class SftpClient extends events_1.EventEmitter { constructor(_name, concurrencyOptions) { super(); this.client = null; this.config = null; this.activeOperations = new Map(); this.operationCounter = 0; this.concurrencyOptions = { maxConcurrentOps: 10, queueOnLimit: false }; this.operationQueue = []; this.processingQueue = false; // Enhanced event system this.eventOptions = { enableProgressEvents: true, enablePerformanceEvents: false, enableAdaptiveEvents: true, progressThrottle: 100, // 100ms between progress events maxEventHistory: 1000, debugMode: false }; this.operationIdCounter = 0; this.batchIdCounter = 0; this.eventHistory = []; this.lastProgressEmit = new Map(); // operation_id -> timestamp this.performanceMetrics = { totalOperations: 0, totalBytes: 0, totalDuration: 0, avgThroughput: 0, currentConcurrency: 0, maxConcurrency: 0 }; // name parameter for ssh2-sftp-client compatibility if (concurrencyOptions) { this.concurrencyOptions = { ...this.concurrencyOptions, ...concurrencyOptions }; } // Debug logging for SFTP operations this.on('debug', (msg) => { try { const fs = require('fs'); const debugMsg = `${new Date().toISOString()} - ${msg}\\n`; fs.appendFileSync('/tmp/pure-js-sftp-debug.log', debugMsg); } catch (e) { /* ignore */ } }); // Operation lifecycle events this.on('operationStart', (op) => { this.emit('debug', `Operation started: ${op.type} ${op.id}`); }); this.on('operationComplete', (op) => { this.emit('debug', `Operation completed: ${op.type} ${op.id} (${Date.now() - op.startTime}ms)`); }); this.on('operationError', (opOrEvent, error) => { if (error) { // Legacy format: (ActiveOperation, Error) const op = opOrEvent; this.emit('debug', `Operation failed: ${op.type} ${op.id} - ${error.message}`); } else { // Enhanced format: (EnhancedOperationEvent) const event = opOrEvent; const errorMsg = event.error?.message || 'Unknown error'; this.emit('debug', `Operation failed: ${event.type} ${event.operation_id} - ${errorMsg}`); } }); this.on('operationProgress', (op) => { if (op.totalBytes && op.bytesTransferred) { const progress = Math.round((op.bytesTransferred / op.totalBytes) * 100); this.emit('debug', `Operation progress: ${op.type} ${op.id} - ${progress}%`); } }); } // Enhanced Event System Methods /** * Configure event system options for VSCode and other applications */ setEventOptions(options) { this.eventOptions = { ...this.eventOptions, ...options }; if (this.eventOptions.debugMode) { this.emit('debug', `Event options updated: ${JSON.stringify(this.eventOptions)}`); } } /** * Get current event system configuration */ getEventOptions() { return { ...this.eventOptions }; } /** * Generate unique operation ID */ generateOperationId() { return `op_${++this.operationIdCounter}_${Date.now()}`; } /** * Generate unique batch ID */ generateBatchId() { return `batch_${++this.batchIdCounter}_${Date.now()}`; } /** * Extract filename from path for display purposes */ extractFileName(filePath) { return filePath.split('/').pop() || filePath.split('\\').pop() || filePath; } /** * Classify error for better user messaging */ classifyError(error, context) { const classifiedError = error; // Network errors if (error.message.includes('ECONNREFUSED') || error.message.includes('ENOTFOUND') || error.message.includes('ETIMEDOUT') || error.message.includes('ECONNRESET')) { classifiedError.category = 'network'; classifiedError.isUserActionable = true; classifiedError.suggestedAction = 'check_network'; classifiedError.isRetryable = true; } // Authentication errors else if (error.message.includes('authentication') || error.message.includes('password') || error.message.includes('key') || error.message.includes('auth')) { classifiedError.category = 'authentication'; classifiedError.isUserActionable = true; classifiedError.suggestedAction = 'check_permissions'; classifiedError.isRetryable = false; } // Permission errors else if (error.message.includes('permission') || error.message.includes('denied') || error.message.includes('EACCES')) { classifiedError.category = 'permission'; classifiedError.isUserActionable = true; classifiedError.suggestedAction = 'check_permissions'; classifiedError.isRetryable = false; } // Timeout errors else if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) { classifiedError.category = 'timeout'; classifiedError.isUserActionable = true; classifiedError.suggestedAction = 'retry'; classifiedError.isRetryable = true; } // Server errors else if (error.message.includes('server') || error.message.includes('EOF') || error.message.includes('protocol')) { classifiedError.category = 'server'; classifiedError.isUserActionable = false; classifiedError.suggestedAction = 'contact_admin'; classifiedError.isRetryable = true; } // Filesystem errors else if (error.message.includes('ENOENT') || error.message.includes('file') || error.message.includes('directory')) { classifiedError.category = 'filesystem'; classifiedError.isUserActionable = true; classifiedError.suggestedAction = 'check_permissions'; classifiedError.isRetryable = false; } // Default to protocol error else { classifiedError.category = 'protocol'; classifiedError.isUserActionable = false; classifiedError.suggestedAction = 'retry'; classifiedError.isRetryable = true; } return classifiedError; } /** * Add event to history for debugging and tracking */ addToEventHistory(event, data) { if (!this.eventOptions.debugMode) return; this.eventHistory.push({ timestamp: Date.now(), event, data: JSON.parse(JSON.stringify(data)) // deep clone to avoid reference issues }); // Trim history if it gets too large if (this.eventHistory.length > this.eventOptions.maxEventHistory) { this.eventHistory = this.eventHistory.slice(-this.eventOptions.maxEventHistory); } } /** * Emit enhanced operation start event */ emitOperationStart(event) { this.addToEventHistory('operationStart', event); this.emit('operationStart', event); if (this.eventOptions.debugMode) { this.emit('debug', `Enhanced operation start: ${event.type} ${event.operation_id} -> ${event.remotePath}`); } } /** * Emit enhanced operation progress event with throttling */ emitOperationProgress(event) { if (!this.eventOptions.enableProgressEvents) return; const now = Date.now(); const lastEmit = this.lastProgressEmit.get(event.operation_id) || 0; // Throttle progress events if (now - lastEmit < this.eventOptions.progressThrottle) { return; } this.lastProgressEmit.set(event.operation_id, now); this.addToEventHistory('operationProgress', event); this.emit('operationProgress', event); } /** * Emit enhanced operation complete event */ emitOperationComplete(event) { this.lastProgressEmit.delete(event.operation_id); this.addToEventHistory('operationComplete', event); this.emit('operationComplete', event); // Update performance metrics this.performanceMetrics.totalOperations++; if (event.totalBytes) { this.performanceMetrics.totalBytes += event.totalBytes; } if (event.duration) { this.performanceMetrics.totalDuration += event.duration; this.performanceMetrics.avgThroughput = this.performanceMetrics.totalBytes / (this.performanceMetrics.totalDuration / 1000) / (1024 * 1024); } } /** * Emit enhanced operation error event */ emitOperationError(event) { this.lastProgressEmit.delete(event.operation_id); this.addToEventHistory('operationError', event); this.emit('operationError', event); } /** * Emit connection state events */ emitConnectionEvent(eventType, data) { this.addToEventHistory(eventType, data); this.emit(eventType, data); } /** * Emit performance metrics event */ emitPerformanceMetrics(data) { if (!this.eventOptions.enablePerformanceEvents) return; this.addToEventHistory('performanceMetrics', data); this.emit('performanceMetrics', data); } /** * Emit adaptive change event */ emitAdaptiveChange(data) { if (!this.eventOptions.enableAdaptiveEvents) return; this.addToEventHistory('adaptiveChange', data); this.emit('adaptiveChange', data); } /** * Emit operation retry event */ emitOperationRetry(data) { this.addToEventHistory('operationRetry', data); this.emit('operationRetry', data); } /** * Emit server limit detected event */ emitServerLimitDetected(data) { this.addToEventHistory('serverLimitDetected', data); this.emit('serverLimitDetected', data); } /** * Emit batch operation events */ emitBatchOperation(eventType, data) { this.addToEventHistory(eventType, data); this.emit(eventType, data); } /** * Get event history for debugging */ getEventHistory() { return [...this.eventHistory]; } /** * Clear event history */ clearEventHistory() { this.eventHistory = []; this.lastProgressEmit.clear(); } /** * Get current performance metrics */ getPerformanceMetrics() { return { ...this.performanceMetrics }; } checkConnection() { if (!this.client) throw new Error('Not connected'); if (!this.client.isReady()) throw new Error('SFTP connection is not ready'); } // Operation tracking and management methods createOperation(type, localPath, remotePath) { const id = `op_${++this.operationCounter}_${Date.now()}`; const operation = { id, type, startTime: Date.now() }; if (localPath) operation.localPath = localPath; if (remotePath) operation.remotePath = remotePath; this.activeOperations.set(id, operation); this.emit('operationStart', operation); return operation; } completeOperation(operation) { this.activeOperations.delete(operation.id); this.emit('operationComplete', operation); this.processQueue(); // Process next queued operation if any } failOperation(operation, error) { this.activeOperations.delete(operation.id); this.emit('operationError', operation, error); this.processQueue(); // Process next queued operation if any } updateOperationProgress(operation, bytesTransferred, totalBytes) { operation.bytesTransferred = bytesTransferred; if (totalBytes) operation.totalBytes = totalBytes; this.emit('operationProgress', operation); } async executeWithConcurrencyControl(operationFn) { if (this.activeOperations.size >= this.concurrencyOptions.maxConcurrentOps) { if (this.concurrencyOptions.queueOnLimit) { // Queue the operation return new Promise((resolve, reject) => { this.operationQueue.push(async () => { try { const result = await operationFn(); resolve(result); } catch (error) { reject(error); } }); }); } else { throw new Error(`Maximum concurrent operations limit reached (${this.concurrencyOptions.maxConcurrentOps}). Currently active: ${this.activeOperations.size}`); } } return operationFn(); } async processQueue() { if (this.processingQueue || this.operationQueue.length === 0) return; if (this.activeOperations.size >= this.concurrencyOptions.maxConcurrentOps) return; this.processingQueue = true; while (this.operationQueue.length > 0 && this.activeOperations.size < this.concurrencyOptions.maxConcurrentOps) { const operation = this.operationQueue.shift(); if (operation) { // Execute without awaiting to allow parallel processing operation().catch(() => { }); // Errors handled in individual operations } } this.processingQueue = false; } // Public API for operation management getActiveOperations() { return Array.from(this.activeOperations.values()); } getActiveOperationCount() { return this.activeOperations.size; } getQueuedOperationCount() { return this.operationQueue.length; } cancelAllOperations() { // Clear the queue this.operationQueue.length = 0; // Cancel active operations (they'll fail naturally when connection is lost) const activeOps = Array.from(this.activeOperations.values()); this.activeOperations.clear(); activeOps.forEach(op => { this.emit('operationError', op, new Error('Operation cancelled')); }); } updateConcurrencyOptions(options) { this.concurrencyOptions = { ...this.concurrencyOptions, ...options }; // Process queue in case we increased the limit this.processQueue(); } // Generic pipelined operation method for both uploads and downloads async executePipelinedOperation(handle, dataSize, operation, chunkOperation, options) { const results = []; let currentOffset = 0; const maxConcurrentOps = options?.maxConcurrentOps ?? 12; // Reduced to be more server-friendly const chunkTimeout = options?.chunkTimeout ?? this.config?.chunkTimeout ?? this.client.getTransport().getAdaptiveTimeout('data', dataSize); // Fully adaptive chunking based on server capabilities and performance const getChunkSize = (bytesProcessed) => { // Use server-adaptive chunk sizing - no hardcoded values return this.client.getTransport().getAdaptiveChunkSize('download', bytesProcessed); }; while (currentOffset < dataSize) { const chunkSize = getChunkSize(currentOffset); const dynamicConcurrency = this.client.getOptimalConcurrency(chunkSize); // Create batch of chunks const chunks = []; let batchOffset = currentOffset; for (let i = 0; i < dynamicConcurrency && batchOffset < dataSize; i++) { const end = Math.min(batchOffset + chunkSize, dataSize); const size = end - batchOffset; chunks.push({ offset: batchOffset, size }); batchOffset += size; } this.emit('debug', `Pipelined batch: ${chunks.length} chunks of ${chunkSize} bytes each (dynamic concurrency: ${dynamicConcurrency})`); // Execute batch with timeout const chunkPromises = chunks.map(async (chunk) => { const chunkStartTime = Date.now(); const operationPromise = chunkOperation(chunk.offset, chunk.size); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Pipelined chunk timeout after ${chunkTimeout}ms for chunk size ${chunk.size}`)); }, chunkTimeout); }); const result = await Promise.race([operationPromise, timeoutPromise]); const chunkDuration = Date.now() - chunkStartTime; this.emit('debug', `Pipelined chunk completed: offset=${chunk.offset}, size=${chunk.size}, duration=${chunkDuration}ms`); return result; }); // Wait for all chunks in this batch to complete const batchResults = await Promise.all(chunkPromises); results.push(...batchResults); currentOffset = batchOffset; this.updateOperationProgress(operation, currentOffset, dataSize); } return results; } // ssh2-sftp-client compatible connect method async connect(config) { // Store config for timeout access this.config = config; // Emit connection start event this.emitConnectionEvent('connectionStart', { host: config.host, port: config.port || 22, username: config.username }); // Disconnect existing connection if any if (this.client) { this.disconnect(); } // Emit authenticating event this.emitConnectionEvent('authenticating', { host: config.host, authType: config.privateKey ? 'key' : 'password' }); try { this.client = new ssh2_streams_client_1.SSH2StreamsSFTPClient(config); } catch (error) { this.emitConnectionEvent('connectionError', { host: config.host, error: error, phase: 'connect' }); throw error; } // Forward events with enhancements this.client.on('ready', () => { this.emit('ready'); this.emitConnectionEvent('connectionReady', { host: config.host, serverInfo: {}, // TODO: Extract server info if available capabilities: {} // TODO: Extract SFTP capabilities if available }); }); this.client.on('error', (err) => { this.emit('error', err); this.emitConnectionEvent('connectionError', { host: config.host, error: err, phase: 'ready' }); // Auto-cleanup on error this.client = null; }); this.client.on('close', () => { this.client = null; // Clear client on close this.emit('close'); }); this.client.on('debug', (msg) => this.emit('debug', msg)); // Forward health check and connection monitoring events this.client.on('keepalive', (event) => this.emit('keepalive', event)); this.client.on('healthCheck', (event) => this.emit('healthCheck', event)); this.client.on('reconnectAttempt', (event) => this.emit('reconnectAttempt', event)); this.client.on('reconnectSuccess', (event) => this.emit('reconnectSuccess', event)); this.client.on('reconnectError', (event) => this.emit('reconnectError', event)); this.client.on('reconnectFailed', (event) => this.emit('reconnectFailed', event)); try { // Add configurable connection timeout (default 30 seconds) const connectTimeout = this.config?.connectTimeout ?? 30000; const connectPromise = this.client.connect(); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Connection timeout after ${connectTimeout}ms`)); }, connectTimeout); }); await Promise.race([connectPromise, timeoutPromise]); } catch (error) { this.client = null; // Clear client on connection failure throw error; } } // ssh2-sftp-client compatible API methods async list(remotePath, filter) { return this.executeWithConcurrencyControl(async () => { this.checkConnection(); const operation = this.createOperation('list', undefined, remotePath); try { const entries = await this.client.listDirectory(remotePath); this.emit('debug', `Listed ${entries.length} items in ${remotePath}`); // Convert DirectoryEntry[] to FileInfo[] for ssh2-sftp-client compatibility const fileInfos = entries.map(entry => { // Determine file type let type = '-'; // default to file if (entry.attrs.isDirectory?.()) { type = 'd'; } else if (entry.attrs.isSymbolicLink?.()) { type = 'l'; } // Parse permissions from mode const mode = entry.attrs.permissions || 0; const formatPermissions = (perm) => { const r = (perm & 4) ? 'r' : '-'; const w = (perm & 2) ? 'w' : '-'; const x = (perm & 1) ? 'x' : '-'; return r + w + x; }; const fileInfo = { type, name: entry.filename, size: entry.attrs.size || 0, modifyTime: entry.attrs.mtime || 0, accessTime: entry.attrs.atime || 0, rights: { user: formatPermissions((mode >> 6) & 7), group: formatPermissions((mode >> 3) & 7), other: formatPermissions(mode & 7) }, owner: entry.attrs.uid || 0, group: entry.attrs.gid || 0 }; return fileInfo; }); // Apply filter if provided const result = filter ? fileInfos.filter(filter) : fileInfos; this.completeOperation(operation); return result; } catch (error) { this.failOperation(operation, error instanceof Error ? error : new Error(String(error))); throw error; } }); } async get(remotePath, dst) { return this.executeWithConcurrencyControl(async () => { this.checkConnection(); const localPath = typeof dst === 'string' ? dst : undefined; // Use enhanced events if enabled, otherwise use legacy events let operation; let operationId; let startEvent; if (this.eventOptions.enableProgressEvents) { // Enhanced event system operationId = this.generateOperationId(); // Get file stats first for size info let fileSize = 0; try { const stats = await this.client.stat(remotePath); fileSize = stats.size || 0; } catch (error) { // Continue without size info } startEvent = { type: 'download', operation_id: operationId, remotePath, totalBytes: fileSize, fileName: this.extractFileName(remotePath), startTime: Date.now() }; if (localPath) { startEvent.localPath = localPath; } this.emitOperationStart(startEvent); } else { // Legacy event system operation = this.createOperation('download', localPath, remotePath); operationId = operation.id; } try { let handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.READ); try { // Get file size first const stats = await this.client.stat(remotePath); const fileSize = stats.size || 0; let fileBuffer; if (fileSize === 0) { // Empty file fileBuffer = Buffer.alloc(0); } else { // Use optimized sequential downloads - reliable and reasonably fast this.emit('debug', `Using optimized sequential downloads: ${fileSize} bytes`); const chunks = []; let offset = 0; let consecutiveSuccesses = 0; while (offset < fileSize) { // Check if we need to reconnect BEFORE making the request if (this.client.isApproachingLimit()) { this.emit('debug', `Approaching server limits, initiating auto-reconnection at ${(offset / (1024 * 1024)).toFixed(2)}MB`); handle = await this.handleAutoReconnection(handle, remotePath); consecutiveSuccesses = 0; // Reset success counter after reconnection } // Use fully adaptive chunk size based on server performance const chunkSize = this.client.getTransport().getAdaptiveChunkSize('download', offset); const requestSize = Math.min(chunkSize, fileSize - offset); const startTime = Date.now(); try { // Use adaptive timeout based on request size and server performance const timeout = this.client.getTransport().getAdaptiveTimeout('data', requestSize); const data = await this.client.readFile(handle, offset, requestSize, timeout); const transferTime = Date.now() - startTime; if (data && data.length > 0) { chunks.push(data); offset += data.length; consecutiveSuccesses++; // Update progress if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced progress event const progressEvent = { ...startEvent, bytesTransferred: offset, totalBytes: fileSize }; this.emitOperationProgress(progressEvent); } else if (operation) { // Legacy progress event this.updateOperationProgress(operation, offset, fileSize); } // Aggressive server-friendly throttling for large files const transport = this.client.getTransport(); const avgResponseTime = transport.adaptiveMetrics.avgResponseTime; const fileIsMedium = fileSize > 1024 * 1024; // > 1MB const fileIsLarge = fileSize > 10 * 1024 * 1024; // > 10MB // Calculate adaptive throttle intervals let throttleInterval = 0; let throttleFrequency = 0; if (fileIsLarge) { // Large files: throttle every 5-10 chunks with longer delays throttleFrequency = Math.max(5, Math.min(10, Math.floor(avgResponseTime / 10))); throttleInterval = Math.max(20, Math.min(100, avgResponseTime * 2)); } else if (fileIsMedium) { // Medium files: throttle every 10-15 chunks with moderate delays throttleFrequency = Math.max(10, Math.min(15, Math.floor(avgResponseTime / 5))); throttleInterval = Math.max(10, Math.min(50, avgResponseTime)); } else { // Small files: minimal throttling throttleFrequency = 25; throttleInterval = Math.max(5, Math.min(20, avgResponseTime / 2)); } if ((chunks.length % throttleFrequency) === 0 && chunks.length > 0) { await new Promise(resolve => setTimeout(resolve, throttleInterval)); } // Additional throttling if server is showing stress (slower response times) if (avgResponseTime > 50 && consecutiveSuccesses > 0 && (consecutiveSuccesses % 10) === 0) { const stressThrottle = Math.min(200, avgResponseTime * 3); await new Promise(resolve => setTimeout(resolve, stressThrottle)); } // Report success metrics with response time const speed = (data.length / (transferTime / 1000)) / (1024 * 1024); this.client.reportTransferMetrics(speed, false, transferTime); } else { // EOF reached break; } } catch (error) { const errorTime = Date.now(); consecutiveSuccesses = 0; // Report timeout for adaptation with response time if (error instanceof Error && error.message.includes('timeout')) { this.client.reportTransferMetrics(0, true, errorTime - startTime); // Record this as a server limit detection const stats = this.client.getOperationStats(); this.client.recordServerLimit(stats.operations, stats.bytesTransferred); // Attempt recovery with reconnection this.emit('debug', `Download timeout at ${(offset / (1024 * 1024)).toFixed(2)}MB, attempting recovery reconnection`); try { handle = await this.handleAutoReconnection(handle, remotePath); this.emit('debug', 'Recovery reconnection successful, retrying read operation'); continue; // Retry the same offset with new connection } catch (reconnectError) { this.emit('debug', `Recovery reconnection failed: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`); throw error; // Throw original timeout error if reconnection fails } } // Handle EOF as a normal end-of-file condition if (error instanceof types_1.SFTPError && error.code === types_1.SFTP_STATUS.EOF) { break; } throw error; } } fileBuffer = Buffer.concat(chunks); } // Update final progress if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced final progress event const progressEvent = { ...startEvent, bytesTransferred: fileBuffer.length, totalBytes: fileBuffer.length }; this.emitOperationProgress(progressEvent); } else if (operation) { // Legacy final progress event this.updateOperationProgress(operation, fileBuffer.length, fileBuffer.length); } let result; if (dst === undefined) { // No destination specified - return Buffer result = fileBuffer; } else if (typeof dst === 'string') { // String destination - write to local file await fsPromises.writeFile(dst, fileBuffer); result = dst; } else { // Writable stream destination result = await new Promise((resolve, reject) => { dst.write(fileBuffer, (error) => { if (error) { reject(error); } else { dst.end(); resolve(dst); } }); }); } if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced completion event const completeEvent = { ...startEvent, duration: Date.now() - startEvent.startTime, bytesTransferred: fileSize }; this.emitOperationComplete(completeEvent); } else if (operation) { // Legacy completion event this.completeOperation(operation); } return result; } catch (error) { if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced error event const errorEvent = { ...startEvent, duration: Date.now() - startEvent.startTime, error: error instanceof Error ? error : new Error(String(error)) }; this.emitOperationError(errorEvent); } else if (operation) { // Legacy error event this.failOperation(operation, error instanceof Error ? error : new Error(String(error))); } throw error; } finally { try { await this.client.closeFile(handle); } catch (closeError) { // Log close error but don't fail the operation since data transfer succeeded this.emit('debug', `Warning: File close failed: ${closeError instanceof Error ? closeError.message : String(closeError)}`); } } } catch (error) { if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced error event const errorEvent = { ...startEvent, duration: Date.now() - startEvent.startTime, error: error instanceof Error ? error : new Error(String(error)) }; this.emitOperationError(errorEvent); } else if (operation) { // Legacy error event this.failOperation(operation, error instanceof Error ? error : new Error(String(error))); } throw error; } }); } async put(input, remotePath, options) { return this.executeWithConcurrencyControl(async () => { this.checkConnection(); const localPath = typeof input === 'string' ? input : undefined; // Use enhanced events if enabled, otherwise use legacy events let operation; let operationId; let startEvent; if (this.eventOptions.enableProgressEvents) { // Enhanced event system operationId = this.generateOperationId(); // Estimate file size if possible let fileSize = 0; if (typeof input === 'string') { try { const stats = require('fs').statSync(input); fileSize = stats.size; } catch (error) { // Continue without size info } } else if (Buffer.isBuffer(input)) { fileSize = input.length; } startEvent = { type: 'upload', operation_id: operationId, remotePath, fileName: this.extractFileName(remotePath), startTime: Date.now() }; if (localPath) { startEvent.localPath = localPath; } if (fileSize > 0) { startEvent.totalBytes = fileSize; } this.emitOperationStart(startEvent); } else { // Legacy event system operation = this.createOperation('upload', localPath, remotePath); operationId = operation.id; } try { this.emit('debug', `Starting upload to: ${remotePath}`); let handle; try { handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.WRITE | types_1.SFTP_OPEN_FLAGS.CREAT | types_1.SFTP_OPEN_FLAGS.TRUNC); this.emit('debug', `File opened successfully, handle size: ${handle.length}`); } catch (openError) { this.emit('debug', `Failed to open file for writing: ${openError instanceof Error ? openError.message : String(openError)}`); throw openError; } try { let dataBuffer; if (Buffer.isBuffer(input)) { // Input is already a Buffer dataBuffer = input; this.emit('debug', `Using provided Buffer: ${dataBuffer.length} bytes`); } else if (typeof input === 'string') { // Input is a file path - read the file this.emit('debug', `Reading file from: ${input}`); dataBuffer = await fsPromises.readFile(input); this.emit('debug', `File read successfully: ${dataBuffer.length} bytes`); } else { // Input is a Readable stream - collect all data this.emit('debug', `Reading from stream`); const chunks = []; await new Promise((resolve, reject) => { input.on('data', (chunk) => { chunks.push(chunk); this.emit('debug', `Stream chunk received: ${chunk.length} bytes`); }); input.on('end', () => { this.emit('debug', `Stream ended`); resolve(); }); input.on('error', (error) => { this.emit('debug', `Stream error: ${error instanceof Error ? error.message : String(error)}`); reject(error); }); }); dataBuffer = Buffer.concat(chunks); this.emit('debug', `Stream data collected: ${dataBuffer.length} bytes from ${chunks.length} chunks`); } // Update operation with total bytes if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced initial progress event const progressEvent = { ...startEvent, bytesTransferred: 0, totalBytes: dataBuffer.length }; this.emitOperationProgress(progressEvent); } else if (operation) { // Legacy initial progress event this.updateOperationProgress(operation, 0, dataBuffer.length); } // Check if dataBuffer is empty (this could cause issues) if (dataBuffer.length === 0) { this.emit('debug', `Warning: Attempting to upload empty file`); // Create empty file by just closing the handle if (this.eventOptions.enableProgressEvents && startEvent) { // Enhanced completion event for empty file const completeEvent = { ...startEvent, duration: Date.now() - startEvent.startTime, bytesTransferred: 0 }; this.emitOperationComplete(completeEvent); } else if (operation) { // Legacy completion event for empty file this.completeOperation(operation); } return remotePath; } // Determine write strategy const usePipelined = options?.usePipelined ?? (dataBuffer.length > 64 * 1024); // Auto-enable for files > 64KB // Use dynamic concurrency based on actual window size and chunk size // Will be calculated per chunk size as it grows from 8KB to 32KB let maxConcurrentWrites = options?.maxConcurrentWrites; // User override, or calculated dynamically // Shared adaptive chunking state - proper progression with conservative overhead accounted // CRITICAL FIX: Account for variable SFTP overhead (66 bytes = SSH headers + SFTP headers + handle) const maxSafeChunkSize = this.client?.getMaxSafeChunkSize() ?? 32702; // Default safe size for 32KB SSH packets const maxChunkSize = Math.min(131072, maxSafeChunkSize); // Respect SSH packet limits // Start with server-adaptive chunk size let chunkSize = this.client.getTransport().getAdaptiveChunkSize('upload', 0); let stableChunkSize = chunkSize; // Last known good chunk size let consecutiveTimeouts = 0; let hasFailedAtSize = false; // Track if we've failed at current size this.emit('debug', `Max chunk size limited to ${maxChunkSize} bytes (SSH limit: ${maxSafeChunkSize})`); let pipelinedSucceeded = false; // Helper function for adaptive chunk size management const handleChunkSuccess = () => { consecutiveTimeouts = 0; // Reset timeout counter on success // Optimized chunking: double size after each successful packet (if not failed before) if (!hasFailedAtSize && chunkSize < maxChunkSize) { // Calculate next size with proper overhead accounting let targetSize; const overhead = 66; // Conservative SFTP + SSH overhead if (chunkSize <= (8192 - overhead)) { targetSize = Math.min(16384 - overhead, maxSafeChunkSize); // Progress to 16KB chunk } else if (chunkSize <= (16384 - overhead)) { targetSize = Math.min(32768 - overhead, maxSafeChunkSize); // Progress to 32KB chunk } else { targetSize = maxChunkSize; // Already at max } const newChunkSize = Math.min(maxChunkSize, targetSize); if (newChunkSize !== chunkSize) { stableChunkSize = chunkSize; // Remember last good size chunkSize = newChunkSize; this.emit('debug', `Progressing chunk size to ${chunkSize} bytes (${Math.round(chunkSize / 1024)}KB with overhead accounted)`); } } };