molex-ftp-client 1.0.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/LICENSE +15 -0
- package/README.md +398 -0
- package/index.js +476 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Tony Wiedman / MolexWorks
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# molex-ftp-client
|
|
2
|
+
|
|
3
|
+
Lightweight FTP client built with native Node.js TCP sockets (net module).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ **Zero dependencies** - Uses only native Node.js modules
|
|
8
|
+
- ✅ **Promise-based API** - Modern async/await support
|
|
9
|
+
- ✅ **Passive mode** (PASV) for data transfers
|
|
10
|
+
- ✅ **Debug logging** - Optional verbose logging for troubleshooting
|
|
11
|
+
- ✅ **Connection keep-alive** - Automatic TCP keep-alive
|
|
12
|
+
- ✅ **Configurable timeouts** - Prevent hanging connections
|
|
13
|
+
- ✅ **Event-based** - Listen to FTP responses and events
|
|
14
|
+
- ✅ **Upload/download** files with Buffer support
|
|
15
|
+
- ✅ **Directory operations** (list, cd, mkdir, pwd)
|
|
16
|
+
- ✅ **File operations** (delete, rename, size, exists, modifiedTime)
|
|
17
|
+
- ✅ **Connection statistics** - Track command count and status
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install molex-ftp-client
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
const FTPClient = require('molex-ftp-client');
|
|
29
|
+
|
|
30
|
+
const client = new FTPClient({
|
|
31
|
+
debug: true, // Enable debug logging (default: false)
|
|
32
|
+
timeout: 30000, // Command timeout in ms (default: 30000)
|
|
33
|
+
keepAlive: true // Enable TCP keep-alive (default: true)
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Connect to FTP server
|
|
38
|
+
await client.connect({
|
|
39
|
+
host: 'ftp.example.com',
|
|
40
|
+
port: 21,
|
|
41
|
+
user: 'username',
|
|
42
|
+
password: 'password'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Upload file
|
|
46
|
+
await client.upload('Hello World!', '/remote/path/file.txt');
|
|
47
|
+
|
|
48
|
+
// Download file
|
|
49
|
+
const data = await client.download('/remote/path/file.txt');
|
|
50
|
+
console.log(data.toString());
|
|
51
|
+
|
|
52
|
+
// Close connection
|
|
53
|
+
await client.close();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('FTP Error:', err);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Constructor Options
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
const client = new FTPClient({
|
|
63
|
+
debug: false, // Enable debug logging
|
|
64
|
+
timeout: 30000, // Command timeout in milliseconds
|
|
65
|
+
keepAlive: true, // Enable TCP keep-alive
|
|
66
|
+
logger: console.log // Custom logger function
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### Connection
|
|
73
|
+
|
|
74
|
+
#### `connect(options)`
|
|
75
|
+
|
|
76
|
+
Connect to FTP server.
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
await client.connect({
|
|
80
|
+
host: 'ftp.example.com', // Required
|
|
81
|
+
port: 21, // Default: 21
|
|
82
|
+
user: 'username', // Default: 'anonymous'
|
|
83
|
+
password: 'password' // Default: 'anonymous@'
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Returns: `Promise<void>`
|
|
88
|
+
|
|
89
|
+
#### `close()` / `disconnect()`
|
|
90
|
+
|
|
91
|
+
Close connection to FTP server.
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
await client.close();
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Returns: `Promise<void>`
|
|
98
|
+
|
|
99
|
+
### File Operations
|
|
100
|
+
|
|
101
|
+
#### `upload(data, remotePath)`
|
|
102
|
+
|
|
103
|
+
Upload file to server.
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
// Upload string
|
|
107
|
+
await client.upload('Hello World!', '/path/file.txt');
|
|
108
|
+
|
|
109
|
+
// Upload Buffer
|
|
110
|
+
const buffer = Buffer.from('data');
|
|
111
|
+
await client.upload(buffer, '/path/file.bin');
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Returns: `Promise<void>`
|
|
115
|
+
|
|
116
|
+
#### `download(remotePath)`
|
|
117
|
+
|
|
118
|
+
Download file from server.
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
const data = await client.download('/path/file.txt');
|
|
122
|
+
console.log(data.toString()); // Convert Buffer to string
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Returns: `Promise<Buffer>`
|
|
126
|
+
|
|
127
|
+
#### `delete(path)`
|
|
128
|
+
|
|
129
|
+
Delete file.
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
await client.delete('/path/file.txt');
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Returns: `Promise<void>`
|
|
136
|
+
|
|
137
|
+
#### `rename(from, to)`
|
|
138
|
+
|
|
139
|
+
Rename or move file.
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
await client.rename('/old/path.txt', '/new/path.txt');
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Returns: `Promise<void>`
|
|
146
|
+
|
|
147
|
+
#### `size(path)`
|
|
148
|
+
|
|
149
|
+
Get file size in bytes.
|
|
150
|
+
|
|
151
|
+
```javascript
|
|
152
|
+
const bytes = await client.size('/path/file.txt');
|
|
153
|
+
console.log(`File size: ${bytes} bytes`);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Returns: `Promise<number>`
|
|
157
|
+
|
|
158
|
+
#### `exists(path)`
|
|
159
|
+
|
|
160
|
+
Check if file exists.
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
const exists = await client.exists('/path/file.txt');
|
|
164
|
+
console.log(exists ? 'File exists' : 'File not found');
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Returns: `Promise<boolean>`
|
|
168
|
+
|
|
169
|
+
#### `modifiedTime(path)`
|
|
170
|
+
|
|
171
|
+
Get file modification time.
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
const date = await client.modifiedTime('/path/file.txt');
|
|
175
|
+
console.log(`Last modified: ${date.toISOString()}`);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Returns: `Promise<Date>`
|
|
179
|
+
|
|
180
|
+
### Directory Operations
|
|
181
|
+
|
|
182
|
+
#### `list(path)`
|
|
183
|
+
|
|
184
|
+
List directory contents.
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
const listing = await client.list('/remote/path');
|
|
188
|
+
console.log(listing);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Returns: `Promise<string>` - Raw directory listing
|
|
192
|
+
|
|
193
|
+
#### `cd(path)`
|
|
194
|
+
|
|
195
|
+
Change working directory.
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
await client.cd('/remote/path');
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Returns: `Promise<void>`
|
|
202
|
+
|
|
203
|
+
#### `pwd()`
|
|
204
|
+
|
|
205
|
+
Get current working directory.
|
|
206
|
+
|
|
207
|
+
```javascript
|
|
208
|
+
const dir = await client.pwd();
|
|
209
|
+
console.log(`Current directory: ${dir}`);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Returns: `Promise<string>`
|
|
213
|
+
|
|
214
|
+
#### `mkdir(path)`
|
|
215
|
+
|
|
216
|
+
Create directory.
|
|
217
|
+
|
|
218
|
+
```javascript
|
|
219
|
+
await client.mkdir('/remote/newdir');
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Returns: `Promise<void>`
|
|
223
|
+
|
|
224
|
+
### Utilities
|
|
225
|
+
|
|
226
|
+
#### `getStats()`
|
|
227
|
+
|
|
228
|
+
Get connection statistics.
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
const stats = client.getStats();
|
|
232
|
+
console.log(stats);
|
|
233
|
+
// {
|
|
234
|
+
// connected: true,
|
|
235
|
+
// authenticated: true,
|
|
236
|
+
// commandCount: 5,
|
|
237
|
+
// lastCommand: 'LIST .'
|
|
238
|
+
// }
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Returns: `Object`
|
|
242
|
+
|
|
243
|
+
#### `setDebug(enabled)`
|
|
244
|
+
|
|
245
|
+
Enable or disable debug mode at runtime.
|
|
246
|
+
|
|
247
|
+
```javascript
|
|
248
|
+
client.setDebug(true); // Enable debug logging
|
|
249
|
+
client.setDebug(false); // Disable debug logging
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Events
|
|
253
|
+
|
|
254
|
+
The client extends EventEmitter and emits the following events:
|
|
255
|
+
|
|
256
|
+
### `connected`
|
|
257
|
+
|
|
258
|
+
Fired when TCP connection is established.
|
|
259
|
+
|
|
260
|
+
```javascript
|
|
261
|
+
client.on('connected', () => {
|
|
262
|
+
console.log('Connected to FTP server');
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### `response`
|
|
267
|
+
|
|
268
|
+
Fired for each FTP response (useful for debugging).
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
client.on('response', (line) => {
|
|
272
|
+
console.log('FTP:', line);
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### `error`
|
|
277
|
+
|
|
278
|
+
Fired on connection errors.
|
|
279
|
+
|
|
280
|
+
```javascript
|
|
281
|
+
client.on('error', (err) => {
|
|
282
|
+
console.error('FTP Error:', err);
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### `close`
|
|
287
|
+
|
|
288
|
+
Fired when connection is closed.
|
|
289
|
+
|
|
290
|
+
```javascript
|
|
291
|
+
client.on('close', () => {
|
|
292
|
+
console.log('Connection closed');
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Debug Mode
|
|
297
|
+
|
|
298
|
+
Enable debug logging to troubleshoot FTP issues:
|
|
299
|
+
|
|
300
|
+
```javascript
|
|
301
|
+
const client = new FTPClient({ debug: true });
|
|
302
|
+
|
|
303
|
+
client.on('response', (line) => {
|
|
304
|
+
console.log('FTP Response:', line);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await client.connect({ host: 'ftp.example.com', user: 'user', password: 'pass' });
|
|
308
|
+
// [FTP Debug] Connecting to ftp.example.com:21 as user
|
|
309
|
+
// [FTP Debug] TCP connection established
|
|
310
|
+
// [FTP Debug] <<< 220 Welcome to FTP server
|
|
311
|
+
// [FTP Debug] >>> USER user
|
|
312
|
+
// [FTP Debug] <<< 331 Password required
|
|
313
|
+
// [FTP Debug] >>> PASS ********
|
|
314
|
+
// [FTP Debug] <<< 230 Login successful
|
|
315
|
+
// [FTP Debug] Authentication successful
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Error Handling
|
|
319
|
+
|
|
320
|
+
All methods return promises and will reject on errors:
|
|
321
|
+
|
|
322
|
+
```javascript
|
|
323
|
+
try {
|
|
324
|
+
await client.upload('data', '/readonly/file.txt');
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err.message.includes('FTP Error 550')) {
|
|
327
|
+
console.error('Permission denied');
|
|
328
|
+
} else {
|
|
329
|
+
console.error('Upload failed:', err.message);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Complete Example
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
const FTPClient = require('molex-ftp-client');
|
|
338
|
+
|
|
339
|
+
async function backupFile() {
|
|
340
|
+
const client = new FTPClient({
|
|
341
|
+
debug: true,
|
|
342
|
+
timeout: 60000
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
// Connect
|
|
347
|
+
await client.connect({
|
|
348
|
+
host: 'ftp.myserver.com',
|
|
349
|
+
port: 21,
|
|
350
|
+
user: 'admin',
|
|
351
|
+
password: 'secret123'
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
console.log('Current directory:', await client.pwd());
|
|
355
|
+
|
|
356
|
+
// Check if file exists
|
|
357
|
+
const exists = await client.exists('/backup/data.json');
|
|
358
|
+
if (exists) {
|
|
359
|
+
// Download existing file
|
|
360
|
+
const oldData = await client.download('/backup/data.json');
|
|
361
|
+
console.log('Old backup size:', oldData.length, 'bytes');
|
|
362
|
+
|
|
363
|
+
// Get modification time
|
|
364
|
+
const modTime = await client.modifiedTime('/backup/data.json');
|
|
365
|
+
console.log('Last modified:', modTime.toISOString());
|
|
366
|
+
|
|
367
|
+
// Rename old backup
|
|
368
|
+
await client.rename('/backup/data.json', '/backup/data.old.json');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Upload new backup
|
|
372
|
+
const newData = JSON.stringify({ timestamp: Date.now(), data: [1, 2, 3] });
|
|
373
|
+
await client.upload(newData, '/backup/data.json');
|
|
374
|
+
console.log('Backup uploaded successfully');
|
|
375
|
+
|
|
376
|
+
// Verify
|
|
377
|
+
const size = await client.size('/backup/data.json');
|
|
378
|
+
console.log('New backup size:', size, 'bytes');
|
|
379
|
+
|
|
380
|
+
// Get stats
|
|
381
|
+
const stats = client.getStats();
|
|
382
|
+
console.log('Commands executed:', stats.commandCount);
|
|
383
|
+
|
|
384
|
+
// Close connection
|
|
385
|
+
await client.close();
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error('Backup failed:', err.message);
|
|
388
|
+
await client.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
backupFile();
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## License
|
|
396
|
+
|
|
397
|
+
ISC © Tony Wiedman / MolexWorks
|
|
398
|
+
|
package/index.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const { EventEmitter } = require('events');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lightweight FTP Client using native Node.js TCP sockets (net module)
|
|
6
|
+
*/
|
|
7
|
+
class FTPClient extends EventEmitter {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this.socket = null;
|
|
11
|
+
this.dataSocket = null;
|
|
12
|
+
this.buffer = '';
|
|
13
|
+
this.connected = false;
|
|
14
|
+
this.authenticated = false;
|
|
15
|
+
this.debug = options.debug || false;
|
|
16
|
+
this.timeout = options.timeout || 30000;
|
|
17
|
+
this.keepAlive = options.keepAlive !== false;
|
|
18
|
+
this._log = options.logger || console.log;
|
|
19
|
+
this._commandCount = 0;
|
|
20
|
+
this._lastCommand = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Log message if debug is enabled
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
_debug(...args) {
|
|
28
|
+
if (this.debug && this._log) {
|
|
29
|
+
this._log('[FTP Debug]', ...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Connect to FTP server
|
|
35
|
+
* @param {Object} options - Connection options
|
|
36
|
+
* @param {string} options.host - FTP server host
|
|
37
|
+
* @param {number} [options.port=21] - FTP server port
|
|
38
|
+
* @param {string} [options.user='anonymous'] - Username
|
|
39
|
+
* @param {string} [options.password='anonymous@'] - Password
|
|
40
|
+
* @returns {Promise<void>}
|
|
41
|
+
*/
|
|
42
|
+
async connect({ host, port = 21, user = 'anonymous', password = 'anonymous@' }) {
|
|
43
|
+
this._debug(`Connecting to ${host}:${port} as ${user}`);
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
this.socket = net.createConnection({ host, port }, () => {
|
|
46
|
+
this.connected = true;
|
|
47
|
+
this._debug('TCP connection established');
|
|
48
|
+
if (this.keepAlive) {
|
|
49
|
+
this.socket.setKeepAlive(true, 10000);
|
|
50
|
+
}
|
|
51
|
+
this.emit('connected');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.socket.setEncoding('utf8');
|
|
55
|
+
this.socket.on('data', async (data) => {
|
|
56
|
+
this.buffer += data;
|
|
57
|
+
const lines = this.buffer.split('\r\n');
|
|
58
|
+
this.buffer = lines.pop();
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (line) {
|
|
62
|
+
this._debug('<<<', line);
|
|
63
|
+
this.emit('response', line);
|
|
64
|
+
const code = parseInt(line.substring(0, 3));
|
|
65
|
+
|
|
66
|
+
// Handle initial connection
|
|
67
|
+
if (code === 220 && !this.authenticated) {
|
|
68
|
+
try {
|
|
69
|
+
this._debug('Authenticating...');
|
|
70
|
+
await this._sendCommand(`USER ${user}`);
|
|
71
|
+
await this._sendCommand(`PASS ${password}`);
|
|
72
|
+
this.authenticated = true;
|
|
73
|
+
this._debug('Authentication successful');
|
|
74
|
+
resolve();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
reject(err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.socket.on('error', (err) => {
|
|
84
|
+
this.emit('error', err);
|
|
85
|
+
reject(err);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.socket.on('close', () => {
|
|
89
|
+
this.connected = false;
|
|
90
|
+
this.authenticated = false;
|
|
91
|
+
this.emit('close');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
setTimeout(() => reject(new Error('Connection timeout')), 10000);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Send FTP command and wait for response
|
|
100
|
+
* @param {string} command - FTP command
|
|
101
|
+
* @param {boolean} allowPreliminary - Allow 1xx preliminary responses
|
|
102
|
+
* @returns {Promise<Object>}
|
|
103
|
+
*/
|
|
104
|
+
_sendCommand(command, allowPreliminary = false) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
if (!this.connected) {
|
|
107
|
+
return reject(new Error('Not connected'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this._commandCount++;
|
|
111
|
+
this._lastCommand = command;
|
|
112
|
+
const cmdToLog = command.startsWith('PASS ') ? 'PASS ********' : command;
|
|
113
|
+
this._debug('>>>', cmdToLog);
|
|
114
|
+
|
|
115
|
+
const timeoutId = setTimeout(() => {
|
|
116
|
+
this.removeListener('response', responseHandler);
|
|
117
|
+
reject(new Error(`Command timeout: ${cmdToLog}`));
|
|
118
|
+
}, this.timeout);
|
|
119
|
+
|
|
120
|
+
const responseHandler = (line) => {
|
|
121
|
+
clearTimeout(timeoutId);
|
|
122
|
+
const code = parseInt(line.substring(0, 3));
|
|
123
|
+
const message = line.substring(4);
|
|
124
|
+
|
|
125
|
+
// Check if this is a complete response (not a multi-line response in progress)
|
|
126
|
+
if (line.charAt(3) === ' ') {
|
|
127
|
+
// 1xx = Preliminary positive reply (command okay, another command expected)
|
|
128
|
+
// 2xx = Positive completion reply
|
|
129
|
+
// 3xx = Positive intermediate reply (command okay, awaiting more info)
|
|
130
|
+
// 4xx/5xx = Negative replies (errors)
|
|
131
|
+
|
|
132
|
+
if (code >= 100 && code < 200 && allowPreliminary) {
|
|
133
|
+
// Don't remove listener, wait for final response
|
|
134
|
+
this._debug('Preliminary response, waiting for completion...');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
this.removeListener('response', responseHandler);
|
|
140
|
+
|
|
141
|
+
if (code >= 200 && code < 400) {
|
|
142
|
+
resolve({ code, message, raw: line });
|
|
143
|
+
} else {
|
|
144
|
+
this._debug(`Error response: ${code}`);
|
|
145
|
+
reject(new Error(`FTP Error ${code}: ${message}`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.on('response', responseHandler);
|
|
151
|
+
this.socket.write(command + '\r\n');
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Enter passive mode and get data connection info
|
|
157
|
+
* @returns {Promise<Object>}
|
|
158
|
+
*/
|
|
159
|
+
async _enterPassiveMode() {
|
|
160
|
+
const response = await this._sendCommand('PASV');
|
|
161
|
+
const match = response.message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
|
|
162
|
+
|
|
163
|
+
if (!match) {
|
|
164
|
+
throw new Error('Failed to parse PASV response');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
|
|
168
|
+
const port = parseInt(match[5]) * 256 + parseInt(match[6]);
|
|
169
|
+
|
|
170
|
+
return { host, port };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Upload file to FTP server
|
|
175
|
+
* @param {string|Buffer} data - File data
|
|
176
|
+
* @param {string} remotePath - Remote file path
|
|
177
|
+
* @returns {Promise<void>}
|
|
178
|
+
*/
|
|
179
|
+
async upload(data, remotePath) {
|
|
180
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
181
|
+
this._debug(`Uploading ${buffer.length} bytes to ${remotePath}`);
|
|
182
|
+
const { host, port } = await this._enterPassiveMode();
|
|
183
|
+
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
let commandSent = false;
|
|
186
|
+
|
|
187
|
+
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
188
|
+
// Send STOR command to start upload (expects 150, then 226)
|
|
189
|
+
if (!commandSent) {
|
|
190
|
+
commandSent = true;
|
|
191
|
+
this._debug(`Data connection established for upload`);
|
|
192
|
+
this._sendCommand(`STOR ${remotePath}`, true).catch(reject);
|
|
193
|
+
|
|
194
|
+
// Write data to data socket
|
|
195
|
+
this.dataSocket.write(buffer);
|
|
196
|
+
this.dataSocket.end();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
this.dataSocket.on('error', reject);
|
|
201
|
+
|
|
202
|
+
this.dataSocket.on('close', () => {
|
|
203
|
+
// Wait for final response from control socket
|
|
204
|
+
const finalHandler = (line) => {
|
|
205
|
+
const code = parseInt(line.substring(0, 3));
|
|
206
|
+
if (code === 226 || code === 250) {
|
|
207
|
+
this.removeListener('response', finalHandler);
|
|
208
|
+
this._debug(`Upload completed successfully`);
|
|
209
|
+
resolve();
|
|
210
|
+
} else if (code >= 400) {
|
|
211
|
+
this.removeListener('response', finalHandler);
|
|
212
|
+
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
this.on('response', finalHandler);
|
|
216
|
+
|
|
217
|
+
// Timeout if no response
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
this.removeListener('response', finalHandler);
|
|
220
|
+
resolve();
|
|
221
|
+
}, 5000);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Download file from FTP server
|
|
228
|
+
* @param {string} remotePath - Remote file path
|
|
229
|
+
* @returns {Promise<Buffer>}
|
|
230
|
+
*/
|
|
231
|
+
async download(remotePath) {
|
|
232
|
+
this._debug(`Downloading ${remotePath}`);
|
|
233
|
+
const { host, port } = await this._enterPassiveMode();
|
|
234
|
+
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const chunks = [];
|
|
237
|
+
let commandSent = false;
|
|
238
|
+
|
|
239
|
+
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
240
|
+
// Send RETR command to start download (expects 150, then 226)
|
|
241
|
+
if (!commandSent) {
|
|
242
|
+
commandSent = true;
|
|
243
|
+
this._debug(`Data connection established for download`);
|
|
244
|
+
this._sendCommand(`RETR ${remotePath}`, true).catch(reject);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this.dataSocket.on('data', (chunk) => {
|
|
249
|
+
chunks.push(chunk);
|
|
250
|
+
this._debug(`Received ${chunk.length} bytes`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.dataSocket.on('error', reject);
|
|
254
|
+
|
|
255
|
+
this.dataSocket.on('close', () => {
|
|
256
|
+
// Wait for final 226 response
|
|
257
|
+
const finalHandler = (line) => {
|
|
258
|
+
const code = parseInt(line.substring(0, 3));
|
|
259
|
+
if (code === 226 || code === 250) {
|
|
260
|
+
this.removeListener('response', finalHandler);
|
|
261
|
+
const result = Buffer.concat(chunks);
|
|
262
|
+
this._debug(`Download completed: ${result.length} bytes`);
|
|
263
|
+
resolve(result);
|
|
264
|
+
} else if (code >= 400) {
|
|
265
|
+
this.removeListener('response', finalHandler);
|
|
266
|
+
reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
this.on('response', finalHandler);
|
|
270
|
+
|
|
271
|
+
// Timeout if no response
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
this.removeListener('response', finalHandler);
|
|
274
|
+
if (chunks.length > 0) {
|
|
275
|
+
resolve(Buffer.concat(chunks));
|
|
276
|
+
}
|
|
277
|
+
}, 5000);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* List directory contents
|
|
284
|
+
* @param {string} [path='.'] - Directory path
|
|
285
|
+
* @returns {Promise<string>}
|
|
286
|
+
*/
|
|
287
|
+
async list(path = '.') {
|
|
288
|
+
this._debug(`Listing directory: ${path}`);
|
|
289
|
+
const { host, port } = await this._enterPassiveMode();
|
|
290
|
+
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const chunks = [];
|
|
293
|
+
let commandSent = false;
|
|
294
|
+
|
|
295
|
+
this.dataSocket = net.createConnection({ host, port }, () => {
|
|
296
|
+
if (!commandSent) {
|
|
297
|
+
commandSent = true;
|
|
298
|
+
this._sendCommand(`LIST ${path}`, true).catch(reject);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.dataSocket.on('data', (chunk) => {
|
|
303
|
+
chunks.push(chunk);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.dataSocket.on('error', reject);
|
|
307
|
+
|
|
308
|
+
this.dataSocket.on('close', () => {
|
|
309
|
+
// Wait for final 226 response
|
|
310
|
+
const finalHandler = (line) => {
|
|
311
|
+
const code = parseInt(line.substring(0, 3));
|
|
312
|
+
if (code === 226 || code === 250) {
|
|
313
|
+
this.removeListener('response', finalHandler);
|
|
314
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
this.on('response', finalHandler);
|
|
318
|
+
|
|
319
|
+
// Timeout fallback
|
|
320
|
+
setTimeout(() => {
|
|
321
|
+
this.removeListener('response', finalHandler);
|
|
322
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
323
|
+
}, 3000);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Change working directory
|
|
330
|
+
* @param {string} path - Directory path
|
|
331
|
+
* @returns {Promise<void>}
|
|
332
|
+
*/
|
|
333
|
+
async cd(path) {
|
|
334
|
+
await this._sendCommand(`CWD ${path}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get current working directory
|
|
339
|
+
* @returns {Promise<string>}
|
|
340
|
+
*/
|
|
341
|
+
async pwd() {
|
|
342
|
+
const response = await this._sendCommand('PWD');
|
|
343
|
+
const match = response.message.match(/"(.+)"/);
|
|
344
|
+
return match ? match[1] : '/';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Create directory
|
|
349
|
+
* @param {string} path - Directory path
|
|
350
|
+
* @returns {Promise<void>}
|
|
351
|
+
*/
|
|
352
|
+
async mkdir(path) {
|
|
353
|
+
await this._sendCommand(`MKD ${path}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Delete file
|
|
358
|
+
* @param {string} path - File path
|
|
359
|
+
* @returns {Promise<void>}
|
|
360
|
+
*/
|
|
361
|
+
async delete(path) {
|
|
362
|
+
await this._sendCommand(`DELE ${path}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Rename file
|
|
367
|
+
* @param {string} from - Current name
|
|
368
|
+
* @param {string} to - New name
|
|
369
|
+
* @returns {Promise<void>}
|
|
370
|
+
*/
|
|
371
|
+
async rename(from, to) {
|
|
372
|
+
await this._sendCommand(`RNFR ${from}`);
|
|
373
|
+
await this._sendCommand(`RNTO ${to}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Get file size
|
|
378
|
+
* @param {string} path - File path
|
|
379
|
+
* @returns {Promise<number>}
|
|
380
|
+
*/
|
|
381
|
+
async size(path) {
|
|
382
|
+
this._debug(`Getting size of ${path}`)
|
|
383
|
+
const response = await this._sendCommand(`SIZE ${path}`);
|
|
384
|
+
return parseInt(response.message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Check if file or directory exists
|
|
389
|
+
* @param {string} path - File or directory path
|
|
390
|
+
* @returns {Promise<boolean>}
|
|
391
|
+
*/
|
|
392
|
+
async exists(path) {
|
|
393
|
+
try {
|
|
394
|
+
await this.size(path);
|
|
395
|
+
return true;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get file modification time
|
|
403
|
+
* @param {string} path - File path
|
|
404
|
+
* @returns {Promise<Date>}
|
|
405
|
+
*/
|
|
406
|
+
async modifiedTime(path) {
|
|
407
|
+
this._debug(`Getting modification time of ${path}`);
|
|
408
|
+
const response = await this._sendCommand(`MDTM ${path}`);
|
|
409
|
+
// Parse MDTM response: YYYYMMDDhhmmss
|
|
410
|
+
const match = response.message.match(/(\d{14})/);
|
|
411
|
+
if (match) {
|
|
412
|
+
const str = match[1];
|
|
413
|
+
const year = parseInt(str.substring(0, 4));
|
|
414
|
+
const month = parseInt(str.substring(4, 6)) - 1;
|
|
415
|
+
const day = parseInt(str.substring(6, 8));
|
|
416
|
+
const hour = parseInt(str.substring(8, 10));
|
|
417
|
+
const minute = parseInt(str.substring(10, 12));
|
|
418
|
+
const second = parseInt(str.substring(12, 14));
|
|
419
|
+
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
420
|
+
}
|
|
421
|
+
throw new Error('Failed to parse MDTM response');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get connection statistics
|
|
426
|
+
* @returns {Object}
|
|
427
|
+
*/
|
|
428
|
+
getStats() {
|
|
429
|
+
return {
|
|
430
|
+
connected: this.connected,
|
|
431
|
+
authenticated: this.authenticated,
|
|
432
|
+
commandCount: this._commandCount,
|
|
433
|
+
lastCommand: this._lastCommand
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Enable or disable debug mode
|
|
439
|
+
* @param {boolean} enabled - Enable debug mode
|
|
440
|
+
*/
|
|
441
|
+
setDebug(enabled) {
|
|
442
|
+
this.debug = enabled;
|
|
443
|
+
this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Close connection
|
|
448
|
+
* @returns {Promise<void>}
|
|
449
|
+
*/
|
|
450
|
+
async close() {
|
|
451
|
+
if (this.connected) {
|
|
452
|
+
this._debug('Closing connection...');
|
|
453
|
+
try {
|
|
454
|
+
await this._sendCommand('QUIT');
|
|
455
|
+
} catch (err) {
|
|
456
|
+
this._debug('Error during QUIT:', err.message);
|
|
457
|
+
}
|
|
458
|
+
this.socket.end();
|
|
459
|
+
this.connected = false;
|
|
460
|
+
this.authenticated = false;
|
|
461
|
+
this._debug('Connection closed');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Disconnect (alias for close)
|
|
467
|
+
* @returns {Promise<void>}
|
|
468
|
+
*/
|
|
469
|
+
async disconnect() {
|
|
470
|
+
return this.close();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
module.exports = FTPClient;
|
|
475
|
+
module.exports.FTPClient = FTPClient;
|
|
476
|
+
module.exports.default = FTPClient;
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "molex-ftp-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight FTP client using native Node.js TCP sockets (net module) with zero dependencies",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"ftp",
|
|
11
|
+
"client",
|
|
12
|
+
"tcp",
|
|
13
|
+
"net",
|
|
14
|
+
"native",
|
|
15
|
+
"zero-dependencies",
|
|
16
|
+
"upload",
|
|
17
|
+
"download",
|
|
18
|
+
"file-transfer",
|
|
19
|
+
"passive-mode"
|
|
20
|
+
],
|
|
21
|
+
"author": "Tony Wiedman",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/molexworks/ftp-client"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/molexworks/ftp-client/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/molexworks/ftp-client#readme",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=12.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|