@yarkivaev/source-to-sink 1.0.4 → 1.1.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/index.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * - mqttSource: MQTT subscription source (accepts URL)
13
13
  * - stompSource: STOMP subscription source (accepts URL)
14
14
  * - lokiSource: Loki polling source (accepts URL)
15
+ * - modbusSource: Modbus TCP polling source (accepts host/port)
16
+ * - mqttSink: MQTT publishing sink (accepts URL)
15
17
  *
16
18
  * @example
17
19
  * import { batch, circuit, timedBatch, clock, clickhouseSink, mqttSource } from 'source-to-sink';
@@ -33,3 +35,5 @@ export { default as postgresSink } from './src/postgresSink.js';
33
35
  export { default as mqttSource } from './src/mqttSource.js';
34
36
  export { default as stompSource } from './src/stompSource.js';
35
37
  export { default as lokiSource } from './src/lokiSource.js';
38
+ export { default as modbusSource } from './src/modbusSource.js';
39
+ export { default as mqttSink } from './src/mqttSink.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/source-to-sink",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,8 @@
12
12
  "mqtt": "^5.0.0",
13
13
  "@clickhouse/client": "^1.0.0",
14
14
  "stompit": "^1.0.0",
15
- "pg": "^8.0.0"
15
+ "pg": "^8.0.0",
16
+ "modbus-serial": "^8.0.0"
16
17
  },
17
18
  "devDependencies": {
18
19
  "mocha": "^10.0.0",
@@ -0,0 +1,99 @@
1
+ import ModbusRTU from 'modbus-serial';
2
+ import pollingSource from './pollingSource.js';
3
+
4
+ /**
5
+ * Idle state for Modbus connection.
6
+ *
7
+ * @returns {object} State with connected() returning false
8
+ */
9
+ function idle() {
10
+ return {
11
+ connected() {
12
+ return false;
13
+ }
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Connected state for Modbus connection.
19
+ *
20
+ * @param {object} client - ModbusRTU client
21
+ * @returns {object} State with connected() returning true
22
+ */
23
+ function connected(client) {
24
+ return {
25
+ connected() {
26
+ return true;
27
+ },
28
+ disconnect() {
29
+ client.close();
30
+ }
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Modbus TCP polling source for reading holding registers.
36
+ *
37
+ * Connects to a Modbus TCP device and polls holding registers
38
+ * at a specified interval. Forwards register arrays to the
39
+ * collector via pollingSource.
40
+ *
41
+ * @example
42
+ * const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
43
+ * source.start();
44
+ * // ... later
45
+ * source.stop();
46
+ *
47
+ * @param {string} host - Modbus TCP host address
48
+ * @param {number} port - Modbus TCP port
49
+ * @param {number} address - Starting register address
50
+ * @param {number} count - Number of registers to read
51
+ * @param {number} interval - Polling interval in seconds
52
+ * @param {object} collector - Collector with accept() method
53
+ * @param {object} clk - Clock with millis() method
54
+ * @returns {object} Source with start() and stop() methods
55
+ */
56
+ export default function modbusSource(host, port, address, count, interval, collector, clk) {
57
+ if (typeof host !== 'string' || host.length === 0) {
58
+ throw new Error('Host must be a non-empty string');
59
+ }
60
+ if (typeof port !== 'number' || port <= 0) {
61
+ throw new Error(`Port must be a positive number, got: ${port}`);
62
+ }
63
+ if (typeof address !== 'number' || address < 0) {
64
+ throw new Error(`Address must be a non-negative number, got: ${address}`);
65
+ }
66
+ if (typeof count !== 'number' || count <= 0) {
67
+ throw new Error(`Count must be a positive number, got: ${count}`);
68
+ }
69
+ const client = new ModbusRTU();
70
+ let state = idle();
71
+ const fetch = async () => {
72
+ const result = await client.readHoldingRegisters(address, count);
73
+ return [result.data];
74
+ };
75
+ const source = pollingSource(fetch, interval, collector, clk);
76
+ return {
77
+ /**
78
+ * Connects to the Modbus device and starts polling.
79
+ */
80
+ async start() {
81
+ if (state.connected()) {
82
+ return;
83
+ }
84
+ await client.connectTCP(host, { port });
85
+ state = connected(client);
86
+ source.start();
87
+ },
88
+ /**
89
+ * Stops polling and disconnects from the Modbus device.
90
+ */
91
+ stop() {
92
+ source.stop();
93
+ if (state.connected()) {
94
+ state.disconnect();
95
+ state = idle();
96
+ }
97
+ }
98
+ };
99
+ }
@@ -0,0 +1,98 @@
1
+ import mqtt from 'mqtt';
2
+
3
+ /**
4
+ * Idle state for MQTT sink.
5
+ *
6
+ * @returns {object} State with connected() returning false
7
+ */
8
+ function idle() {
9
+ return {
10
+ connected() {
11
+ return false;
12
+ }
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Connected state for MQTT sink.
18
+ *
19
+ * @param {object} client - MQTT client
20
+ * @returns {object} State with connected() returning true
21
+ */
22
+ function connected(client) {
23
+ return {
24
+ connected() {
25
+ return true;
26
+ },
27
+ publish(topic, payload, qos) {
28
+ client.publish(topic, payload, { qos });
29
+ },
30
+ disconnect() {
31
+ client.end();
32
+ }
33
+ };
34
+ }
35
+
36
+ /**
37
+ * MQTT sink for publishing records to MQTT topics.
38
+ *
39
+ * Each record must have a topic and payload field.
40
+ * Creates an MQTT client internally and manages the connection.
41
+ *
42
+ * @example
43
+ * const sink = mqttSink('mqtt://localhost:1883');
44
+ * sink.start();
45
+ * sink.write([{ topic: 'sensors/temp', payload: '42.5' }]);
46
+ * sink.stop();
47
+ *
48
+ * @param {string} url - MQTT broker URL (e.g., 'mqtt://localhost:1883')
49
+ * @param {object} [options] - Optional MQTT connection options
50
+ * @param {string} [options.clientId] - Client ID for the connection
51
+ * @param {number} [options.qos] - QoS level for publishing (default 0)
52
+ * @returns {object} Sink with write(), start(), and stop() methods
53
+ */
54
+ export default function mqttSink(url, options = {}) {
55
+ if (typeof url !== 'string' || url.length === 0) {
56
+ throw new Error('URL must be a non-empty string');
57
+ }
58
+ const qos = options.qos || 0;
59
+ let state = idle();
60
+ return {
61
+ /**
62
+ * Writes records to MQTT topics.
63
+ *
64
+ * @param {Array} records - Array of {topic, payload} records
65
+ */
66
+ write(records) {
67
+ if (!state.connected()) {
68
+ throw new Error('Sink is not connected');
69
+ }
70
+ for (const record of records) {
71
+ state.publish(record.topic, record.payload, qos);
72
+ }
73
+ },
74
+ /**
75
+ * Connects to the MQTT broker.
76
+ */
77
+ start() {
78
+ if (state.connected()) {
79
+ return;
80
+ }
81
+ const client = mqtt.connect(url, {
82
+ clientId: options.clientId,
83
+ protocolVersion: 5
84
+ });
85
+ state = connected(client);
86
+ },
87
+ /**
88
+ * Disconnects from the MQTT broker.
89
+ */
90
+ stop() {
91
+ if (!state.connected()) {
92
+ return;
93
+ }
94
+ state.disconnect();
95
+ state = idle();
96
+ }
97
+ };
98
+ }
@@ -0,0 +1,101 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it, before, after } from 'mocha';
3
+ import { GenericContainer } from 'testcontainers';
4
+ import mqtt from 'mqtt';
5
+ import mqttSink from '../src/mqttSink.js';
6
+
7
+ describe('mqttSink integration', function() {
8
+ let container;
9
+ let url;
10
+ let subscriber;
11
+
12
+ before(async function() {
13
+ this.timeout(120000);
14
+ container = await new GenericContainer('eclipse-mosquitto:2')
15
+ .withExposedPorts(1883)
16
+ .withCommand(['mosquitto', '-c', '/mosquitto-no-auth.conf'])
17
+ .withStartupTimeout(30000)
18
+ .start();
19
+ const host = container.getHost();
20
+ const port = container.getMappedPort(1883);
21
+ url = `mqtt://${host}:${port}`;
22
+ subscriber = mqtt.connect(url);
23
+ await new Promise((resolve, reject) => {
24
+ subscriber.on('connect', resolve);
25
+ subscriber.on('error', reject);
26
+ setTimeout(() => reject(new Error('MQTT connection timeout')), 10000);
27
+ });
28
+ });
29
+
30
+ after(async function() {
31
+ this.timeout(30000);
32
+ if (subscriber) {
33
+ subscriber.end();
34
+ }
35
+ if (container) {
36
+ await container.stop();
37
+ }
38
+ });
39
+
40
+ it('publishes records that subscriber receives', async function() {
41
+ this.timeout(10000);
42
+ const received = [];
43
+ const topic = `sink/${Math.random().toString(36).slice(2)}`;
44
+ subscriber.subscribe(topic);
45
+ subscriber.on('message', (t, payload) => {
46
+ received.push({ topic: t, payload: payload.toString() });
47
+ });
48
+ await new Promise((resolve) => setTimeout(resolve, 1000));
49
+ const sink = mqttSink(url);
50
+ sink.start();
51
+ await new Promise((resolve) => setTimeout(resolve, 1000));
52
+ const value = `${Math.random()}`;
53
+ sink.write([{ topic, payload: value }]);
54
+ await new Promise((resolve) => setTimeout(resolve, 1000));
55
+ sink.stop();
56
+ assert.strictEqual(received.length >= 1, true, 'Subscriber should receive published record');
57
+ });
58
+
59
+ it('publishes unicode payload', async function() {
60
+ this.timeout(10000);
61
+ const received = [];
62
+ const topic = `юникод/${Math.random().toString(36).slice(2)}`;
63
+ subscriber.subscribe(topic);
64
+ subscriber.on('message', (t, payload) => {
65
+ if (t === topic) {
66
+ received.push(payload.toString());
67
+ }
68
+ });
69
+ await new Promise((resolve) => setTimeout(resolve, 1000));
70
+ const sink = mqttSink(url);
71
+ sink.start();
72
+ await new Promise((resolve) => setTimeout(resolve, 1000));
73
+ const text = `Данные температуры ${Math.random()}`;
74
+ sink.write([{ topic, payload: text }]);
75
+ await new Promise((resolve) => setTimeout(resolve, 1000));
76
+ sink.stop();
77
+ assert.strictEqual(received.length >= 1, true, 'Subscriber should receive unicode payload');
78
+ });
79
+
80
+ it('does not publish after stop', async function() {
81
+ this.timeout(10000);
82
+ const received = [];
83
+ const topic = `stopped/${Math.random().toString(36).slice(2)}`;
84
+ subscriber.subscribe(topic);
85
+ subscriber.on('message', (t, payload) => {
86
+ if (t === topic) {
87
+ received.push(payload.toString());
88
+ }
89
+ });
90
+ await new Promise((resolve) => setTimeout(resolve, 1000));
91
+ const sink = mqttSink(url);
92
+ sink.start();
93
+ await new Promise((resolve) => setTimeout(resolve, 1000));
94
+ sink.stop();
95
+ assert.throws(
96
+ () => sink.write([{ topic, payload: `${Math.random()}` }]),
97
+ /not connected/,
98
+ 'Should throw when writing after stop'
99
+ );
100
+ });
101
+ });
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import modbusSource from '../src/modbusSource.js';
4
+ import fakeClock from './fakeClock.js';
5
+
6
+ describe('modbusSource', () => {
7
+ it('throws on empty host', () => {
8
+ const collector = { accept: () => {} };
9
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
10
+ assert.throws(
11
+ () => modbusSource('', 502, 4000, 14, 5, collector, clk),
12
+ /Host must be a non-empty string/,
13
+ 'Should reject empty host'
14
+ );
15
+ });
16
+
17
+ it('throws on missing host', () => {
18
+ const collector = { accept: () => {} };
19
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
20
+ assert.throws(
21
+ () => modbusSource(null, 502, 4000, 14, 5, collector, clk),
22
+ /Host must be a non-empty string/,
23
+ 'Should reject null host'
24
+ );
25
+ });
26
+
27
+ it('throws on invalid port', () => {
28
+ const collector = { accept: () => {} };
29
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
30
+ assert.throws(
31
+ () => modbusSource('192.168.2.148', -1, 4000, 14, 5, collector, clk),
32
+ /Port must be a positive number/,
33
+ 'Should reject negative port'
34
+ );
35
+ });
36
+
37
+ it('throws on negative address', () => {
38
+ const collector = { accept: () => {} };
39
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
40
+ assert.throws(
41
+ () => modbusSource('192.168.2.148', 502, -1, 14, 5, collector, clk),
42
+ /Address must be a non-negative number/,
43
+ 'Should reject negative address'
44
+ );
45
+ });
46
+
47
+ it('throws on invalid count', () => {
48
+ const collector = { accept: () => {} };
49
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
50
+ assert.throws(
51
+ () => modbusSource('192.168.2.148', 502, 4000, 0, 5, collector, clk),
52
+ /Count must be a positive number/,
53
+ 'Should reject zero count'
54
+ );
55
+ });
56
+
57
+ it('throws on missing collector', () => {
58
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
59
+ assert.throws(
60
+ () => modbusSource('192.168.2.148', 502, 4000, 14, 5, null, clk),
61
+ /Collector must have an accept\(\) method/,
62
+ 'Should reject null collector'
63
+ );
64
+ });
65
+
66
+ it('throws on missing clock', () => {
67
+ const collector = { accept: () => {} };
68
+ assert.throws(
69
+ () => modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, null),
70
+ /Clock must have a millis\(\) method/,
71
+ 'Should reject null clock'
72
+ );
73
+ });
74
+
75
+ it('creates source with start and stop methods', () => {
76
+ const collector = { accept: () => {} };
77
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
78
+ const source = modbusSource(`\u00e9${Math.random()}`, 502, 4000, 14, 5, collector, clk);
79
+ assert.strictEqual(typeof source.start, 'function', 'Should have start method');
80
+ });
81
+
82
+ it('creates source with stop method', () => {
83
+ const collector = { accept: () => {} };
84
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
85
+ const source = modbusSource(`\u4e2d${Math.random()}`, 502, 4000, 14, 5, collector, clk);
86
+ assert.strictEqual(typeof source.stop, 'function', 'Should have stop method');
87
+ });
88
+
89
+ it('stops without error when not started', () => {
90
+ const collector = { accept: () => {} };
91
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
92
+ const source = modbusSource(`\u3042${Math.random()}`, 502, 4000, 14, 5, collector, clk);
93
+ source.stop();
94
+ assert.strictEqual(true, true, 'Should not throw when stopping without start');
95
+ });
96
+ });
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import mqttSink from '../src/mqttSink.js';
4
+
5
+ describe('mqttSink', () => {
6
+ it('throws on empty url', () => {
7
+ assert.throws(
8
+ () => mqttSink(''),
9
+ /URL must be a non-empty string/,
10
+ 'Should reject empty URL'
11
+ );
12
+ });
13
+
14
+ it('throws on missing url', () => {
15
+ assert.throws(
16
+ () => mqttSink(null),
17
+ /URL must be a non-empty string/,
18
+ 'Should reject null URL'
19
+ );
20
+ });
21
+
22
+ it('creates sink with write method', () => {
23
+ const sink = mqttSink(`mqtt://\u00e9${Math.random()}`);
24
+ assert.strictEqual(typeof sink.write, 'function', 'Should have write method');
25
+ });
26
+
27
+ it('creates sink with start method', () => {
28
+ const sink = mqttSink(`mqtt://\u4e2d${Math.random()}`);
29
+ assert.strictEqual(typeof sink.start, 'function', 'Should have start method');
30
+ });
31
+
32
+ it('creates sink with stop method', () => {
33
+ const sink = mqttSink(`mqtt://\u3042${Math.random()}`);
34
+ assert.strictEqual(typeof sink.stop, 'function', 'Should have stop method');
35
+ });
36
+
37
+ it('throws on write when not connected', () => {
38
+ const sink = mqttSink(`mqtt://\u00df${Math.random()}`);
39
+ assert.throws(
40
+ () => sink.write([{ topic: `\u00e9${Math.random()}`, payload: `${Math.random()}` }]),
41
+ /Sink is not connected/,
42
+ 'Should reject write when not connected'
43
+ );
44
+ });
45
+
46
+ it('stops without error when not started', () => {
47
+ const sink = mqttSink(`mqtt://\u0420${Math.random()}`);
48
+ sink.stop();
49
+ assert.strictEqual(true, true, 'Should not throw when stopping without start');
50
+ });
51
+ });