@alt-javascript/camel-lite-component-ftp 1.0.2

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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
2
+
3
+ ## What
4
+
5
+ FTP producer and consumer via [`basic-ftp`](https://www.npmjs.com/package/basic-ftp). Producers upload the exchange body to the configured directory; consumers poll a directory and emit one exchange per file found.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install camel-lite-component-ftp
11
+ ```
12
+
13
+ ## URI Syntax
14
+
15
+ ```
16
+ ftp://host/directory[?username=u&password=p&port=21&passive=true]
17
+ ```
18
+
19
+ | Parameter | Default | Description |
20
+ |------------|---------|-------------|
21
+ | `username` | *(required)* | FTP account username. |
22
+ | `password` | *(required)* | FTP account password. |
23
+ | `port` | `21` | FTP server port. |
24
+ | `passive` | `true` | Use passive mode transfers. Set to `false` for active mode. |
25
+
26
+ ## Usage
27
+
28
+ **Producer — upload exchange body to FTP directory:**
29
+
30
+ ```js
31
+ import { CamelContext } from 'camel-lite-core';
32
+ import { FtpComponent } from 'camel-lite-component-ftp';
33
+
34
+ const context = new CamelContext();
35
+ context.addComponent('ftp', new FtpComponent());
36
+
37
+ context.addRoutes({
38
+ configure(ctx) {
39
+ ctx.from('direct:upload')
40
+ .to('ftp://ftp.example.com/uploads?username=user&password=secret');
41
+ }
42
+ });
43
+
44
+ await context.start();
45
+
46
+ const template = context.createProducerTemplate();
47
+ await template.send('direct:upload', ex => {
48
+ ex.in.body = 'file content here';
49
+ ex.in.setHeader('CamelFileName', 'report.txt');
50
+ });
51
+
52
+ await context.stop();
53
+ ```
54
+
55
+ **Consumer — poll an FTP directory:**
56
+
57
+ ```js
58
+ context.addRoutes({
59
+ configure(ctx) {
60
+ ctx.from('ftp://ftp.example.com/inbox?username=user&password=secret')
61
+ .process(exchange => {
62
+ console.log('Downloaded:', exchange.in.getHeader('CamelFileName'));
63
+ console.log('Content:', exchange.in.body);
64
+ });
65
+ }
66
+ });
67
+ ```
68
+
69
+ ## See Also
70
+
71
+ [camel-lite — root README](../../README.md)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@alt-javascript/camel-lite-component-ftp",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.js"
7
+ },
8
+ "dependencies": {
9
+ "basic-ftp": "^5.0.0",
10
+ "@alt-javascript/logger": "^3.0.7",
11
+ "@alt-javascript/config": "^3.0.7",
12
+ "@alt-javascript/common": "^3.0.7",
13
+ "@alt-javascript/camel-lite-core": "1.0.2"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/alt-javascript/camel-lite"
21
+ },
22
+ "author": "Craig Parravicini",
23
+ "contributors": [
24
+ "Claude (Anthropic)",
25
+ "Apache Camel — design inspiration and pattern source"
26
+ ],
27
+ "keywords": [
28
+ "alt-javascript",
29
+ "camel",
30
+ "camel-lite",
31
+ "eai",
32
+ "eip",
33
+ "integration",
34
+ "ftp",
35
+ "sftp",
36
+ "file-transfer",
37
+ "component"
38
+ ],
39
+ "publishConfig": {
40
+ "registry": "https://registry.npmjs.org/",
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,11 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const basicFtp = require('basic-ftp');
4
+
5
+ /**
6
+ * ESM/CJS bridge for basic-ftp.
7
+ * This is the single point where CJS is loaded — keeps the rest of the package pure ESM.
8
+ */
9
+ export function createFtpClient() {
10
+ return new basicFtp.Client();
11
+ }
@@ -0,0 +1,18 @@
1
+ import { Component } from '@alt-javascript/camel-lite-core';
2
+ import FtpEndpoint from './FtpEndpoint.js';
3
+
4
+ class FtpComponent extends Component {
5
+ #endpoints = new Map();
6
+
7
+ createEndpoint(uri, remaining, parameters, context) {
8
+ if (this.#endpoints.has(uri)) {
9
+ return this.#endpoints.get(uri);
10
+ }
11
+ const endpoint = new FtpEndpoint(uri, remaining, parameters, context);
12
+ this.#endpoints.set(uri, endpoint);
13
+ return endpoint;
14
+ }
15
+ }
16
+
17
+ export { FtpComponent };
18
+ export default FtpComponent;
@@ -0,0 +1,87 @@
1
+ import { Consumer, Exchange } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+ import { Writable } from 'node:stream';
4
+
5
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/FtpConsumer');
6
+
7
+ class FtpConsumer extends Consumer {
8
+ #uri;
9
+ #context;
10
+ #host;
11
+ #port;
12
+ #user;
13
+ #password;
14
+ #remotePath;
15
+ #clientFactory;
16
+
17
+ constructor(uri, context, host, port, user, password, remotePath, clientFactory) {
18
+ super();
19
+ this.#uri = uri;
20
+ this.#context = context;
21
+ this.#host = host;
22
+ this.#port = port;
23
+ this.#user = user;
24
+ this.#password = password;
25
+ this.#remotePath = remotePath;
26
+ this.#clientFactory = clientFactory;
27
+ }
28
+
29
+ get uri() { return this.#uri; }
30
+
31
+ async start() {
32
+ this.#context.registerConsumer(this.#uri, this);
33
+ log.info(`FTP consumer started: ${this.#host}${this.#remotePath}`);
34
+ }
35
+
36
+ async stop() {
37
+ this.#context.registerConsumer(this.#uri, null);
38
+ log.info(`FTP consumer stopped: ${this.#host}${this.#remotePath}`);
39
+ }
40
+
41
+ /**
42
+ * One-shot poll: list remote directory, download each file into an Exchange.
43
+ * Returns an array of Exchange objects, one per remote file.
44
+ */
45
+ async poll() {
46
+ const client = this.#clientFactory();
47
+ try {
48
+ await client.access({
49
+ host: this.#host,
50
+ port: this.#port,
51
+ user: this.#user,
52
+ password: this.#password,
53
+ secure: false,
54
+ });
55
+ log.info(`FTP poll connected: ${this.#host}${this.#remotePath}`);
56
+
57
+ const list = await client.list(this.#remotePath);
58
+ const files = list.filter(item => item.isFile);
59
+ log.debug(`FTP poll found ${files.length} files in ${this.#remotePath}`);
60
+
61
+ const exchanges = [];
62
+ for (const file of files) {
63
+ const remoteName = this.#remotePath.replace(/\/$/, '') + '/' + file.name;
64
+ const chunks = [];
65
+ const writable = new Writable({
66
+ write(chunk, _, cb) { chunks.push(chunk); cb(); },
67
+ });
68
+ await client.downloadTo(writable, remoteName);
69
+ log.debug(`FTP downloaded: ${remoteName}`);
70
+
71
+ const exchange = new Exchange();
72
+ exchange.in.body = Buffer.concat(chunks).toString('utf8');
73
+ exchange.in.setHeader('CamelFileName', file.name);
74
+ exchange.in.setHeader('CamelFtpRemotePath', remoteName);
75
+ exchanges.push(exchange);
76
+ }
77
+
78
+ return exchanges;
79
+ } finally {
80
+ client.close();
81
+ log.info(`FTP poll disconnected: ${this.#host}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ export { FtpConsumer };
87
+ export default FtpConsumer;
@@ -0,0 +1,73 @@
1
+ import { Endpoint } from '@alt-javascript/camel-lite-core';
2
+ import FtpProducer from './FtpProducer.js';
3
+ import FtpConsumer from './FtpConsumer.js';
4
+ import { createFtpClient } from './FtpClientFactory.js';
5
+
6
+ class FtpEndpoint extends Endpoint {
7
+ #uri;
8
+ #host;
9
+ #port;
10
+ #user;
11
+ #password;
12
+ #remotePath;
13
+ #binary;
14
+ #context;
15
+
16
+ constructor(uri, remaining, parameters, context) {
17
+ super();
18
+ this.#uri = uri;
19
+ this.#context = context;
20
+
21
+ // FTP URIs: ftp://user:pass@host:21/remote/path
22
+ // The raw uri from CamelContext has scheme 'ftp', so we reconstruct a URL:
23
+ // 'ftp:user:pass@host:21/path' → need to rebuild as 'ftp://user:pass@host:21/path'
24
+ // Use URL class — it handles authority (user:pass@host:port) correctly.
25
+ // remaining = everything after 'ftp:' stripped of leading '//'
26
+ let fullUri = uri;
27
+ if (!fullUri.startsWith('ftp://') && !fullUri.startsWith('ftps://')) {
28
+ // Camel-style: ftp:host/path or ftp:user:pass@host:21/path
29
+ fullUri = fullUri.replace(/^ftp:/, 'ftp://');
30
+ }
31
+
32
+ try {
33
+ const parsed = new URL(fullUri);
34
+ this.#host = parsed.hostname || 'localhost';
35
+ this.#port = parseInt(parsed.port || '21', 10);
36
+ this.#user = decodeURIComponent(parsed.username || 'anonymous');
37
+ this.#password = decodeURIComponent(parsed.password || '');
38
+ this.#remotePath = parsed.pathname || '/';
39
+ } catch {
40
+ // Fallback for unparseable URIs
41
+ this.#host = 'localhost';
42
+ this.#port = 21;
43
+ this.#user = 'anonymous';
44
+ this.#password = '';
45
+ this.#remotePath = '/' + (remaining || '');
46
+ }
47
+
48
+ const params = parameters instanceof URLSearchParams
49
+ ? parameters
50
+ : new URLSearchParams(typeof parameters === 'string' ? parameters : '');
51
+
52
+ this.#binary = params.get('binary') === 'true';
53
+ }
54
+
55
+ get uri() { return this.#uri; }
56
+ get host() { return this.#host; }
57
+ get port() { return this.#port; }
58
+ get user() { return this.#user; }
59
+ get password() { return this.#password; }
60
+ get remotePath() { return this.#remotePath; }
61
+ get binary() { return this.#binary; }
62
+
63
+ createProducer(clientFactory = createFtpClient) {
64
+ return new FtpProducer(this.#host, this.#port, this.#user, this.#password, this.#remotePath, clientFactory);
65
+ }
66
+
67
+ createConsumer(pipeline, clientFactory = createFtpClient) {
68
+ return new FtpConsumer(this.#uri, this.#context, this.#host, this.#port, this.#user, this.#password, this.#remotePath, clientFactory);
69
+ }
70
+ }
71
+
72
+ export { FtpEndpoint };
73
+ export default FtpEndpoint;
@@ -0,0 +1,61 @@
1
+ import { Producer } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+ import { Readable } from 'node:stream';
4
+
5
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/FtpProducer');
6
+
7
+ class FtpProducer extends Producer {
8
+ #host;
9
+ #port;
10
+ #user;
11
+ #password;
12
+ #remotePath;
13
+ #clientFactory;
14
+
15
+ constructor(host, port, user, password, remotePath, clientFactory) {
16
+ super();
17
+ this.#host = host;
18
+ this.#port = port;
19
+ this.#user = user;
20
+ this.#password = password;
21
+ this.#remotePath = remotePath;
22
+ this.#clientFactory = clientFactory;
23
+ }
24
+
25
+ get host() { return this.#host; }
26
+ get remotePath() { return this.#remotePath; }
27
+
28
+ async send(exchange) {
29
+ const client = this.#clientFactory();
30
+ try {
31
+ await client.access({
32
+ host: this.#host,
33
+ port: this.#port,
34
+ user: this.#user,
35
+ password: this.#password,
36
+ secure: false,
37
+ });
38
+ log.info(`FTP connected: ${this.#host}:${this.#port}`);
39
+
40
+ const body = exchange.in.body ?? '';
41
+ const content = typeof body === 'string' ? body : JSON.stringify(body);
42
+ const stream = Readable.from([content]);
43
+
44
+ // File name: CamelFileName header > remotePath (treated as full remote path)
45
+ const remoteName = exchange.in.getHeader('CamelFtpRemotePath')
46
+ ?? exchange.in.getHeader('CamelFileName')
47
+ ?? this.#remotePath;
48
+
49
+ await client.uploadFrom(stream, remoteName);
50
+ log.debug(`FTP uploaded: ${remoteName}`);
51
+
52
+ exchange.out.setHeader('CamelFtpRemotePath', remoteName);
53
+ } finally {
54
+ client.close();
55
+ log.info(`FTP disconnected: ${this.#host}`);
56
+ }
57
+ }
58
+ }
59
+
60
+ export { FtpProducer };
61
+ export default FtpProducer;
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { FtpComponent } from './FtpComponent.js';
2
+ export { FtpEndpoint } from './FtpEndpoint.js';
3
+ export { FtpProducer } from './FtpProducer.js';
4
+ export { FtpConsumer } from './FtpConsumer.js';
5
+ export { createFtpClient } from './FtpClientFactory.js';
6
+
7
+ import FtpComponent from './FtpComponent.js';
8
+ export default FtpComponent;
@@ -0,0 +1,204 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Writable } from 'node:stream';
4
+ import { CamelContext, Exchange, Component } from '@alt-javascript/camel-lite-core';
5
+ import { FtpComponent, FtpEndpoint, FtpProducer, FtpConsumer } from '@alt-javascript/camel-lite-component-ftp';
6
+
7
+ // Mock FtpClient — records calls, no network activity
8
+ class MockFtpClient {
9
+ calls = [];
10
+ uploaded = {};
11
+ // Files available for listing/downloading
12
+ remoteFiles = [
13
+ { name: 'alpha.txt', isFile: true, size: 10 },
14
+ { name: 'beta.txt', isFile: true, size: 8 },
15
+ ];
16
+ fileContents = {
17
+ '/remote/alpha.txt': 'content-alpha',
18
+ '/remote/beta.txt': 'content-beta',
19
+ };
20
+
21
+ async access(opts) {
22
+ this.calls.push(['access', opts]);
23
+ }
24
+
25
+ async uploadFrom(stream, remotePath) {
26
+ const chunks = [];
27
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
28
+ this.uploaded[remotePath] = Buffer.concat(chunks).toString('utf8');
29
+ this.calls.push(['uploadFrom', remotePath]);
30
+ }
31
+
32
+ async list(remotePath) {
33
+ this.calls.push(['list', remotePath]);
34
+ return this.remoteFiles;
35
+ }
36
+
37
+ async downloadTo(writable, remotePath) {
38
+ const content = this.fileContents[remotePath] ?? 'default-content';
39
+ writable.write(Buffer.from(content));
40
+ writable.end();
41
+ this.calls.push(['downloadTo', remotePath]);
42
+ // Wait for writable to finish
43
+ await new Promise(r => writable.on('finish', r));
44
+ }
45
+
46
+ close() {
47
+ this.calls.push(['close']);
48
+ }
49
+ }
50
+
51
+ function makeMockFactory(mockClient) {
52
+ return () => mockClient;
53
+ }
54
+
55
+ describe('FtpProducer', () => {
56
+ it('send() calls access, uploadFrom, close on the client', async () => {
57
+ const mock = new MockFtpClient();
58
+ const producer = new FtpProducer('localhost', 21, 'user', 'pass', '/remote/out.txt', makeMockFactory(mock));
59
+ const exchange = new Exchange();
60
+ exchange.in.body = 'hello ftp';
61
+
62
+ await producer.send(exchange);
63
+
64
+ assert.ok(mock.calls.some(c => c[0] === 'access'));
65
+ assert.ok(mock.calls.some(c => c[0] === 'uploadFrom'));
66
+ assert.ok(mock.calls.some(c => c[0] === 'close'));
67
+ assert.equal(mock.uploaded['/remote/out.txt'], 'hello ftp');
68
+ assert.equal(exchange.out.getHeader('CamelFtpRemotePath'), '/remote/out.txt');
69
+ });
70
+
71
+ it('send() serialises non-string body as JSON', async () => {
72
+ const mock = new MockFtpClient();
73
+ const producer = new FtpProducer('localhost', 21, 'user', 'pass', '/remote/data.json', makeMockFactory(mock));
74
+ const exchange = new Exchange();
75
+ exchange.in.body = { key: 'value' };
76
+
77
+ await producer.send(exchange);
78
+
79
+ assert.deepEqual(JSON.parse(mock.uploaded['/remote/data.json']), { key: 'value' });
80
+ });
81
+
82
+ it('send() uses CamelFileName header as remote path when set', async () => {
83
+ const mock = new MockFtpClient();
84
+ const producer = new FtpProducer('localhost', 21, 'user', 'pass', '/default.txt', makeMockFactory(mock));
85
+ const exchange = new Exchange();
86
+ exchange.in.body = 'named';
87
+ exchange.in.setHeader('CamelFileName', '/remote/named.txt');
88
+
89
+ await producer.send(exchange);
90
+
91
+ assert.ok('/remote/named.txt' in mock.uploaded);
92
+ assert.equal(mock.uploaded['/remote/named.txt'], 'named');
93
+ });
94
+
95
+ it('send() closes client even when upload fails', async () => {
96
+ const mock = new MockFtpClient();
97
+ mock.uploadFrom = async () => { throw new Error('upload failed'); };
98
+
99
+ const producer = new FtpProducer('localhost', 21, 'user', 'pass', '/remote/fail.txt', makeMockFactory(mock));
100
+ const exchange = new Exchange();
101
+ exchange.in.body = 'data';
102
+
103
+ await assert.rejects(() => producer.send(exchange), { message: 'upload failed' });
104
+ assert.ok(mock.calls.some(c => c[0] === 'close'), 'close should be called even on error');
105
+ });
106
+ });
107
+
108
+ describe('FtpConsumer', () => {
109
+ it('poll() returns one exchange per remote file with correct body', async () => {
110
+ const mock = new MockFtpClient();
111
+ const ctx = new CamelContext();
112
+ const consumer = new FtpConsumer(
113
+ 'ftp://localhost/remote', ctx,
114
+ 'localhost', 21, 'user', 'pass', '/remote',
115
+ makeMockFactory(mock)
116
+ );
117
+ await consumer.start();
118
+
119
+ const exchanges = await consumer.poll();
120
+
121
+ assert.equal(exchanges.length, 2);
122
+ const bodies = exchanges.map(e => e.in.body).sort();
123
+ assert.deepEqual(bodies, ['content-alpha', 'content-beta']);
124
+ assert.ok(mock.calls.some(c => c[0] === 'list'));
125
+ assert.ok(mock.calls.some(c => c[0] === 'downloadTo'));
126
+ assert.ok(mock.calls.some(c => c[0] === 'close'));
127
+
128
+ await consumer.stop();
129
+ });
130
+
131
+ it('poll() sets CamelFileName header on each exchange', async () => {
132
+ const mock = new MockFtpClient();
133
+ const ctx = new CamelContext();
134
+ const consumer = new FtpConsumer(
135
+ 'ftp://localhost/remote', ctx,
136
+ 'localhost', 21, 'user', 'pass', '/remote',
137
+ makeMockFactory(mock)
138
+ );
139
+ await consumer.start();
140
+
141
+ const exchanges = await consumer.poll();
142
+ const names = exchanges.map(e => e.in.getHeader('CamelFileName')).sort();
143
+ assert.deepEqual(names, ['alpha.txt', 'beta.txt']);
144
+
145
+ await consumer.stop();
146
+ });
147
+
148
+ it('start() registers consumer with context; stop() deregisters', async () => {
149
+ const mock = new MockFtpClient();
150
+ const ctx = new CamelContext();
151
+ const consumer = new FtpConsumer(
152
+ 'ftp://localhost/remote', ctx,
153
+ 'localhost', 21, 'user', 'pass', '/remote',
154
+ makeMockFactory(mock)
155
+ );
156
+
157
+ await consumer.start();
158
+ assert.strictEqual(ctx.getConsumer('ftp://localhost/remote'), consumer);
159
+
160
+ await consumer.stop();
161
+ assert.ok(!ctx.getConsumer('ftp://localhost/remote'));
162
+ });
163
+ });
164
+
165
+ describe('FtpEndpoint', () => {
166
+ it('parses ftp:// URI: host, port, user, password, remotePath', () => {
167
+ const ctx = new CamelContext();
168
+ const ep = new FtpEndpoint(
169
+ 'ftp://myuser:mypass@ftphost.example.com:2121/uploads',
170
+ 'uploads',
171
+ new URLSearchParams(),
172
+ ctx
173
+ );
174
+ assert.equal(ep.host, 'ftphost.example.com');
175
+ assert.equal(ep.port, 2121);
176
+ assert.equal(ep.user, 'myuser');
177
+ assert.equal(ep.password, 'mypass');
178
+ assert.equal(ep.remotePath, '/uploads');
179
+ });
180
+
181
+ it('defaults port to 21 when not specified', () => {
182
+ const ctx = new CamelContext();
183
+ const ep = new FtpEndpoint(
184
+ 'ftp://host/path',
185
+ 'path',
186
+ new URLSearchParams(),
187
+ ctx
188
+ );
189
+ assert.equal(ep.port, 21);
190
+ });
191
+ });
192
+
193
+ describe('cross-package import integration', () => {
194
+ it('FtpComponent is a subclass of Component', () => {
195
+ assert.ok(new FtpComponent() instanceof Component);
196
+ });
197
+
198
+ it('FtpComponent.createEndpoint returns FtpEndpoint', () => {
199
+ const ctx = new CamelContext();
200
+ const comp = new FtpComponent();
201
+ const ep = comp.createEndpoint('ftp://localhost/test', 'test', new URLSearchParams(), ctx);
202
+ assert.ok(ep instanceof FtpEndpoint);
203
+ });
204
+ });