molex-ftp-client 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/FTPClient.js CHANGED
@@ -1,7 +1,6 @@
1
1
  const { EventEmitter } = require('events');
2
2
  const FTPConnection = require('./connection');
3
3
  const FTPCommands = require('./commands');
4
- const { PERFORMANCE_PRESETS } = require('./performance');
5
4
 
6
5
  /**
7
6
  * Lightweight FTP Client using native Node.js TCP sockets (net module)
@@ -25,10 +24,8 @@ class FTPClient extends EventEmitter
25
24
  this.keepAlive = options.keepAlive !== false;
26
25
  this._log = options.logger || console.log;
27
26
 
28
- // Performance tuning
29
- this.performancePreset = options.performancePreset || 'BALANCED';
30
- this.performanceOptions = options.performance ||
31
- PERFORMANCE_PRESETS[this.performancePreset] || PERFORMANCE_PRESETS.BALANCED;
27
+ // Statistics
28
+ this._commandCount = 0;
32
29
  this._lastCommand = null;
33
30
 
34
31
  // Initialize subsystems
@@ -168,6 +165,17 @@ class FTPClient extends EventEmitter
168
165
  return this._commands.delete(path);
169
166
  }
170
167
 
168
+ /**
169
+ * Remove directory
170
+ * @param {string} path - Directory path
171
+ * @param {boolean} recursive - Delete all contents recursively (default: false)
172
+ * @returns {Promise<void>}
173
+ */
174
+ async removeDir(path, recursive = false)
175
+ {
176
+ return this._commands.removeDir(path, recursive);
177
+ }
178
+
171
179
  /**
172
180
  * Rename file
173
181
  * @param {string} from - Current name
@@ -199,6 +207,16 @@ class FTPClient extends EventEmitter
199
207
  return this._commands.exists(path);
200
208
  }
201
209
 
210
+ /**
211
+ * Get file/directory information
212
+ * @param {string} path - Path to check
213
+ * @returns {Promise<Object>} - { exists, size, isFile, isDirectory }
214
+ */
215
+ async stat(path)
216
+ {
217
+ return this._commands.stat(path);
218
+ }
219
+
202
220
  /**
203
221
  * Ensure directory exists, creating it if necessary
204
222
  * @param {string} dirPath - Directory or file path to ensure exists
@@ -254,6 +272,37 @@ class FTPClient extends EventEmitter
254
272
  this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
255
273
  }
256
274
 
275
+ /**
276
+ * Change file permissions (Unix/Linux servers only)
277
+ * @param {string} path - File or directory path
278
+ * @param {string|number} mode - Permissions (e.g., '755', 0755)
279
+ * @returns {Promise<void>}
280
+ */
281
+ async chmod(path, mode)
282
+ {
283
+ return this._commands.chmod(path, mode);
284
+ }
285
+
286
+ /**
287
+ * Execute a SITE command (server-specific commands)
288
+ * @param {string} command - SITE command to execute
289
+ * @returns {Promise<Object>}
290
+ */
291
+ async site(command)
292
+ {
293
+ return this._commands.site(command);
294
+ }
295
+
296
+ /**
297
+ * Get detailed directory listing with permissions, owner, size, etc.
298
+ * @param {string} path - Directory path
299
+ * @returns {Promise<Array>}
300
+ */
301
+ async listDetailed(path = '.')
302
+ {
303
+ return this._commands.listDetailed(path);
304
+ }
305
+
257
306
  /**
258
307
  * Close connection
259
308
  * @returns {Promise<void>}
package/lib/commands.js CHANGED
@@ -1,6 +1,5 @@
1
- const net = require('net');
2
1
  const { normalizePath, getParentDir, parseMdtmResponse } = require('./utils');
3
- const { optimizeSocket } = require('./performance');
2
+ const { createOptimizedSocket } = require('./performance');
4
3
 
5
4
  /**
6
5
  * FTP command implementations
@@ -32,15 +31,14 @@ class FTPCommands {
32
31
  return new Promise((resolve, reject) => {
33
32
  let commandSent = false;
34
33
 
35
- this.client.dataSocket = net.createConnection({ host, port }, () => {
36
-
37
- optimizeSocket(this.client.dataSocket, this.client.performanceOptions);
38
-
34
+ this.client.dataSocket = createOptimizedSocket({ host, port }, () => {
39
35
  // Send STOR command to start upload (expects 150, then 226)
40
36
  if (!commandSent) {
41
37
  commandSent = true;
42
38
  this.client._debug(`Data connection established for upload`);
43
- this.connection.sendCommand(`STOR ${remotePath}`, true).catch(reject);
39
+ // Just send command, don't wait for completion
40
+ this.client.socket.write(`STOR ${remotePath}\r\n`);
41
+ this.client._commandCount++;
44
42
 
45
43
  // Write data to data socket
46
44
  this.client.dataSocket.write(buffer);
@@ -51,25 +49,10 @@ class FTPCommands {
51
49
  this.client.dataSocket.on('error', reject);
52
50
 
53
51
  this.client.dataSocket.on('close', () => {
54
- // Wait for final response from control socket
55
- const finalHandler = (line) => {
56
- const code = parseInt(line.substring(0, 3));
57
- if (code === 226 || code === 250) {
58
- this.client.removeListener('response', finalHandler);
59
- this.client._debug(`Upload completed successfully`);
60
- resolve();
61
- } else if (code >= 400) {
62
- this.client.removeListener('response', finalHandler);
63
- reject(new Error(`Upload failed - FTP Error ${code}: ${line.substring(4)} (path: ${remotePath})`));
64
- }
65
- };
66
- this.client.on('response', finalHandler);
67
-
68
- // Timeout if no response
69
- setTimeout(() => {
70
- this.client.removeListener('response', finalHandler);
71
- resolve();
72
- }, this.client.timeout || 5000);
52
+ // Upload complete
53
+ this.client._debug(`Upload completed successfully`);
54
+ // Small delay to let 226 response arrive before next command
55
+ setTimeout(() => resolve(), 10);
73
56
  });
74
57
  });
75
58
  }
@@ -89,49 +72,40 @@ class FTPCommands {
89
72
  return new Promise((resolve, reject) => {
90
73
  const chunks = [];
91
74
  let commandSent = false;
75
+ let dataComplete = false;
76
+ let commandComplete = false;
77
+
78
+ const checkComplete = () => {
79
+ if (dataComplete && commandComplete) {
80
+ const result = Buffer.concat(chunks);
81
+ this.client._debug(`Download completed: ${result.length} bytes`);
82
+ resolve(result);
83
+ }
84
+ };
92
85
 
93
- this.client.dataSocket = net.createConnection({ host, port }, () => {
94
-
95
- optimizeSocket(this.client.dataSocket, this.client.performanceOptions);
96
-
86
+ this.client.dataSocket = createOptimizedSocket({ host, port }, () => {
97
87
  // Send RETR command to start download (expects 150, then 226)
98
88
  if (!commandSent) {
99
89
  commandSent = true;
100
90
  this.client._debug(`Data connection established for download`);
101
- this.connection.sendCommand(`RETR ${remotePath}`, true).catch(reject);
91
+ // Just send command, don't wait for completion - data socket will handle it
92
+ this.client.socket.write(`RETR ${remotePath}\r\n`);
93
+ this.client._commandCount++;
102
94
  }
103
95
  });
104
96
 
105
97
  this.client.dataSocket.on('data', (chunk) => {
106
98
  chunks.push(chunk);
107
- this.client._debug(`Received ${chunk.length} bytes`);
108
99
  });
109
100
 
110
101
  this.client.dataSocket.on('error', reject);
111
102
 
112
103
  this.client.dataSocket.on('close', () => {
113
- // Wait for final 226 response
114
- const finalHandler = (line) => {
115
- const code = parseInt(line.substring(0, 3));
116
- if (code === 226 || code === 250) {
117
- this.client.removeListener('response', finalHandler);
118
- const result = Buffer.concat(chunks);
119
- this.client._debug(`Download completed: ${result.length} bytes`);
120
- resolve(result);
121
- } else if (code >= 400) {
122
- this.client.removeListener('response', finalHandler);
123
- reject(new Error(`Download failed - FTP Error ${code}: ${line.substring(4)} (path: ${remotePath})`));
124
- }
125
- };
126
- this.client.on('response', finalHandler);
127
-
128
- // Timeout if no response
129
- setTimeout(() => {
130
- this.client.removeListener('response', finalHandler);
131
- if (chunks.length > 0) {
132
- resolve(Buffer.concat(chunks));
133
- }
134
- }, this.client.timeout || 5000);
104
+ // Data transfer complete, resolve immediately
105
+ const result = Buffer.concat(chunks);
106
+ this.client._debug(`Download completed: ${result.length} bytes`);
107
+ // Small delay to let 226 response arrive before next command
108
+ setTimeout(() => resolve(result), 10);
135
109
  });
136
110
  });
137
111
  }
@@ -154,14 +128,13 @@ class FTPCommands {
154
128
  let totalBytes = 0;
155
129
  let commandSent = false;
156
130
 
157
- this.client.dataSocket = net.createConnection({ host, port }, () => {
158
-
159
- optimizeSocket(this.client.dataSocket, this.client.performanceOptions);
160
-
131
+ this.client.dataSocket = createOptimizedSocket({ host, port }, () => {
161
132
  if (!commandSent) {
162
133
  commandSent = true;
163
134
  this.client._debug(`Data connection established for streaming download`);
164
- this.connection.sendCommand(`RETR ${remotePath}`, true).catch(reject);
135
+ // Just send command, don't wait for completion
136
+ this.client.socket.write(`RETR ${remotePath}\r\n`);
137
+ this.client._commandCount++;
165
138
  }
166
139
  });
167
140
 
@@ -176,33 +149,11 @@ class FTPCommands {
176
149
  });
177
150
 
178
151
  this.client.dataSocket.on('close', () => {
179
- // Wait for final 226 response
180
- const finalHandler = (line) => {
181
- const code = parseInt(line.substring(0, 3));
182
- if (code === 226 || code === 250) {
183
- this.client.removeListener('response', finalHandler);
184
- writeStream.end();
185
- this.client._debug(`Streaming download completed: ${totalBytes} bytes`);
186
- resolve(totalBytes);
187
- } else if (code >= 400) {
188
- this.client.removeListener('response', finalHandler);
189
- writeStream.end();
190
- reject(new Error(`Download failed - FTP Error ${code}: ${line.substring(4)} (path: ${remotePath})`));
191
- }
192
- };
193
- this.client.on('response', finalHandler);
194
-
195
- // Timeout if no response
196
- setTimeout(() => {
197
- this.client.removeListener('response', finalHandler);
198
- if (totalBytes > 0) {
199
- writeStream.end();
200
- resolve(totalBytes);
201
- } else {
202
- writeStream.end();
203
- reject(new Error('Download timeout'));
204
- }
205
- }, this.client.timeout || 5000);
152
+ // Streaming complete
153
+ writeStream.end();
154
+ this.client._debug(`Streaming download completed: ${totalBytes} bytes`);
155
+ // Small delay to let 226 response arrive before next command
156
+ setTimeout(() => resolve(totalBytes), 10);
206
157
  });
207
158
  });
208
159
  }
@@ -220,13 +171,12 @@ class FTPCommands {
220
171
  const chunks = [];
221
172
  let commandSent = false;
222
173
 
223
- this.client.dataSocket = net.createConnection({ host, port }, () => {
224
-
225
- optimizeSocket(this.client.dataSocket, this.client.performanceOptions);
226
-
174
+ this.client.dataSocket = createOptimizedSocket({ host, port }, () => {
227
175
  if (!commandSent) {
228
176
  commandSent = true;
229
- this.connection.sendCommand(`LIST ${path}`, true).catch(reject);
177
+ // Just send command, don't wait for completion
178
+ this.client.socket.write(`LIST ${path}\r\n`);
179
+ this.client._commandCount++;
230
180
  }
231
181
  });
232
182
 
@@ -237,21 +187,9 @@ class FTPCommands {
237
187
  this.client.dataSocket.on('error', reject);
238
188
 
239
189
  this.client.dataSocket.on('close', () => {
240
- // Wait for final 226 response
241
- const finalHandler = (line) => {
242
- const code = parseInt(line.substring(0, 3));
243
- if (code === 226 || code === 250) {
244
- this.client.removeListener('response', finalHandler);
245
- resolve(Buffer.concat(chunks).toString('utf8'));
246
- }
247
- };
248
- this.client.on('response', finalHandler);
249
-
250
- // Timeout fallback
251
- setTimeout(() => {
252
- this.client.removeListener('response', finalHandler);
253
- resolve(Buffer.concat(chunks).toString('utf8'));
254
- }, this.client.timeout || 3000);
190
+ // Data transfer complete, resolve immediately
191
+ // Small delay to let 226 response arrive before next command
192
+ setTimeout(() => resolve(Buffer.concat(chunks).toString('utf8')), 10);
255
193
  });
256
194
  });
257
195
  }
@@ -293,6 +231,57 @@ class FTPCommands {
293
231
  await this.connection.sendCommand(`DELE ${path}`);
294
232
  }
295
233
 
234
+ /**
235
+ * Remove directory
236
+ * @param {string} path - Directory path
237
+ * @param {boolean} recursive - Delete all contents recursively (default: false)
238
+ * @returns {Promise<void>}
239
+ */
240
+ async removeDir(path, recursive = false) {
241
+ if (!recursive) {
242
+ // Remove empty directory only
243
+ await this.connection.sendCommand(`RMD ${path}`);
244
+ return;
245
+ }
246
+
247
+ // Recursive delete - get contents and delete everything
248
+ try {
249
+ const listing = await this.list(path);
250
+ const lines = listing.split('\n').filter(line => line.trim());
251
+
252
+ // Process each line - faster than listDetailed
253
+ for (const line of lines) {
254
+ // Skip . and .. and empty lines
255
+ if (!line || line.includes(' .') || line.includes(' ..')) continue;
256
+
257
+ // Extract filename (last part of line)
258
+ const parts = line.trim().split(/\s+/);
259
+ const name = parts[parts.length - 1];
260
+ if (name === '.' || name === '..') continue;
261
+
262
+ const fullPath = `${path}/${name}`.replace(/\/+/g, '/');
263
+ const isDir = line.startsWith('d');
264
+
265
+ if (isDir) {
266
+ // Directory - recurse
267
+ await this.removeDir(fullPath, true);
268
+ } else {
269
+ // File - delete
270
+ try {
271
+ await this.delete(fullPath);
272
+ } catch (err) {
273
+ this.client._debug(`Could not delete file ${fullPath}: ${err.message}`);
274
+ }
275
+ }
276
+ }
277
+
278
+ // Remove the now-empty directory
279
+ await this.connection.sendCommand(`RMD ${path}`);
280
+ } catch (err) {
281
+ throw new Error(`Failed to remove directory ${path}: ${err.message}`);
282
+ }
283
+ }
284
+
296
285
  /**
297
286
  * Rename file
298
287
  * @param {string} from - Current name
@@ -321,10 +310,20 @@ class FTPCommands {
321
310
  * @returns {Promise<boolean>}
322
311
  */
323
312
  async exists(path) {
313
+ const info = await this.stat(path);
314
+ return info.exists;
315
+ }
316
+
317
+ /**
318
+ * Get file/directory information
319
+ * @param {string} path - Path to check
320
+ * @returns {Promise<Object>} - { exists, size, isFile, isDirectory }
321
+ */
322
+ async stat(path) {
324
323
  try {
325
324
  // First try SIZE command (works for files)
326
- await this.size(path);
327
- return true;
325
+ const size = await this.size(path);
326
+ return { exists: true, size, isFile: true, isDirectory: false };
328
327
  } catch (err) {
329
328
  // SIZE failed, might be a directory - try CWD
330
329
  try {
@@ -332,10 +331,18 @@ class FTPCommands {
332
331
  await this.cd(path);
333
332
  // Restore original directory
334
333
  await this.cd(currentDir);
335
- return true;
334
+ return { exists: true, size: null, isFile: false, isDirectory: true };
336
335
  } catch (cdErr) {
337
- // Both SIZE and CWD failed - doesn't exist
338
- return false;
336
+ // Both SIZE and CWD failed - try listing parent directory
337
+ try {
338
+ const dir = getParentDir(path);
339
+ const basename = path.split('/').pop();
340
+ const listing = await this.list(dir);
341
+ const found = listing.split('\n').some(line => line.includes(basename));
342
+ return { exists: found, size: null, isFile: null, isDirectory: null };
343
+ } catch (listErr) {
344
+ return { exists: false, size: null, isFile: null, isDirectory: null };
345
+ }
339
346
  }
340
347
  }
341
348
  }
@@ -401,6 +408,71 @@ class FTPCommands {
401
408
  const response = await this.connection.sendCommand(`MDTM ${path}`);
402
409
  return parseMdtmResponse(response.message);
403
410
  }
411
+
412
+ /**
413
+ * Change file permissions (Unix/Linux servers only)
414
+ * @param {string} path - File or directory path
415
+ * @param {string|number} mode - Permissions (e.g., '755', 0755, or 'rwxr-xr-x')
416
+ * @returns {Promise<void>}
417
+ */
418
+ async chmod(path, mode) {
419
+ // Convert numeric mode to octal string if needed
420
+ const modeStr = typeof mode === 'number' ? mode.toString(8) : String(mode).replace(/[^0-7]/g, '');
421
+
422
+ if (!/^[0-7]{3,4}$/.test(modeStr)) {
423
+ throw new Error(`Invalid chmod mode: ${mode}. Use octal format like '755' or 0755`);
424
+ }
425
+
426
+ this.client._debug(`Changing permissions of ${path} to ${modeStr}`);
427
+ await this.connection.sendCommand(`SITE CHMOD ${modeStr} ${path}`);
428
+ }
429
+
430
+ /**
431
+ * Execute a SITE command (server-specific commands)
432
+ * @param {string} command - SITE command to execute (without 'SITE' prefix)
433
+ * @returns {Promise<Object>}
434
+ */
435
+ async site(command) {
436
+ this.client._debug(`Executing SITE command: ${command}`);
437
+ return await this.connection.sendCommand(`SITE ${command}`);
438
+ }
439
+
440
+ /**
441
+ * Parse directory listing into structured objects
442
+ * @param {string} path - Directory path
443
+ * @returns {Promise<Array>} Array of file/directory objects
444
+ */
445
+ async listDetailed(path = '.') {
446
+ const listing = await this.list(path);
447
+ const lines = listing.split('\n').filter(line => line.trim() && !line.startsWith('total'));
448
+
449
+ return lines.map(line => {
450
+ // Parse Unix-style LIST format
451
+ const match = line.match(/^([drwxlst-]{10})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\S+\s+\S+)\s+(.+)$/);
452
+
453
+ if (match) {
454
+ const [, perms, links, owner, group, size, date, name] = match;
455
+ return {
456
+ name,
457
+ type: perms[0] === 'd' ? 'directory' : (perms[0] === 'l' ? 'symlink' : 'file'),
458
+ permissions: perms,
459
+ owner,
460
+ group,
461
+ size: parseInt(size),
462
+ date,
463
+ raw: line
464
+ };
465
+ }
466
+
467
+ // Fallback for non-standard formats - try to extract name
468
+ const parts = line.trim().split(/\s+/);
469
+ return {
470
+ name: parts[parts.length - 1],
471
+ type: 'unknown',
472
+ raw: line
473
+ };
474
+ }).filter(item => item.name && item.name !== '.' && item.name !== '..');
475
+ }
404
476
  }
405
477
 
406
478
  module.exports = FTPCommands;
package/lib/connection.js CHANGED
@@ -1,5 +1,4 @@
1
- const net = require('net');
2
- const { optimizeSocket } = require('./performance');
1
+ const { createOptimizedSocket } = require('./performance');
3
2
 
4
3
  /**
5
4
  * Handle FTP connection establishment and authentication
@@ -22,14 +21,10 @@ class FTPConnection {
22
21
  this.client._debug(`Connecting to ${host}:${port} as ${user}`);
23
22
 
24
23
  return new Promise((resolve, reject) => {
25
- this.client.socket = net.createConnection({ host, port }, () => {
24
+ this.client.socket = createOptimizedSocket({ host, port }, () => {
26
25
  this.client.connected = true;
27
26
  this.client._debug('TCP connection established');
28
27
 
29
- // Apply performance optimizations
30
- optimizeSocket(this.client.socket, this.client.performanceOptions);
31
- this.client._debug('TCP optimizations applied:', this.client.performancePreset);
32
-
33
28
  this.client.emit('connected');
34
29
  });
35
30
 
@@ -1,80 +1,29 @@
1
1
  /**
2
2
  * TCP Performance optimization utilities
3
- * Based on high-performance networking best practices
3
+ * Applies sensible defaults for FTP connections
4
4
  */
5
5
 
6
+ const net = require('net');
7
+
6
8
  /**
7
- * Apply performance optimizations to a TCP socket
8
- * @param {net.Socket} socket - TCP socket to optimize
9
- * @param {Object} options - Performance options
10
- * @param {boolean} options.noDelay - Disable Nagle's algorithm (default: true)
11
- * @param {boolean} options.keepAlive - Enable TCP keep-alive (default: true)
12
- * @param {number} options.keepAliveDelay - Keep-alive initial delay in ms (default: 10000)
9
+ * Create an optimized TCP socket connection
10
+ * Automatically applies TCP_NODELAY and keep-alive
11
+ * @param {Object} options - Connection options (host, port)
12
+ * @param {Function} callback - Callback on connection
13
+ * @returns {net.Socket}
13
14
  */
14
- function optimizeSocket(socket, options = {}) {
15
- // Use defaults if options not provided
16
- const {
17
- noDelay = true,
18
- keepAlive = true,
19
- keepAliveDelay = 10000
20
- } = options || {};
21
-
15
+ function createOptimizedSocket(options, callback) {
16
+ const socket = net.createConnection(options, callback);
17
+
22
18
  // TCP_NODELAY - Disable Nagle's algorithm for lower latency
23
- // Critical for interactive applications and small packet transfers
24
- // Nagle's algorithm buffers small packets, adding latency
25
- if (noDelay) {
26
- socket.setNoDelay(true);
27
- }
28
-
19
+ socket.setNoDelay(true);
20
+
29
21
  // SO_KEEPALIVE - Detect dead connections
30
- if (keepAlive) {
31
- socket.setKeepAlive(true, keepAliveDelay);
32
- }
33
-
22
+ socket.setKeepAlive(true, 10000);
23
+
34
24
  return socket;
35
25
  }
36
26
 
37
- /**
38
- * Performance presets for different use cases
39
- */
40
- const PERFORMANCE_PRESETS = {
41
- // Low latency - prioritize speed over bandwidth
42
- // Good for small files, interactive operations
43
- LOW_LATENCY: {
44
- noDelay: true,
45
- sendBufferSize: 32768, // 32KB
46
- receiveBufferSize: 32768, // 32KB
47
- keepAlive: true,
48
- keepAliveDelay: 5000
49
- },
50
-
51
- // High throughput - prioritize bandwidth over latency
52
- // Good for large file transfers
53
- HIGH_THROUGHPUT: {
54
- noDelay: false, // Allow Nagle's algorithm to batch
55
- sendBufferSize: 131072, // 128KB
56
- receiveBufferSize: 131072, // 128KB
57
- keepAlive: true,
58
- keepAliveDelay: 30000
59
- },
60
-
61
- // Balanced - good default for most use cases
62
- BALANCED: {
63
- noDelay: true,
64
- sendBufferSize: 65536, // 64KB
65
- receiveBufferSize: 65536, // 64KB
66
- keepAlive: true,
67
- keepAliveDelay: 10000
68
- },
69
-
70
- // Default Node.js behavior
71
- DEFAULT: {
72
- noDelay: false,
73
- keepAlive: false
74
- }
75
- };
76
-
77
27
  module.exports = {
78
- optimizeSocket,
79
- PERFORMANCE_PRESETS
28
+ createOptimizedSocket
80
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "molex-ftp-client",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Lightweight FTP client using native Node.js TCP sockets (net module) with zero dependencies",
5
5
  "main": "index.js",
6
6
  "scripts": {