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
JavaScript
"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)`);
}
}
};