@yarkivaev/source-to-sink 1.3.7 → 1.3.9
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 +9 -0
- package/eslint.config.js +1 -0
- package/package.json +1 -1
- package/src/modbusSource.js +38 -3
- package/src/stompSource.js +11 -5
- package/test/test_modbusSource.js +168 -0
- package/test/test_stompSource.js +53 -0
package/README.md
CHANGED
|
@@ -38,3 +38,12 @@ source.start();
|
|
|
38
38
|
## License
|
|
39
39
|
|
|
40
40
|
MIT
|
|
41
|
+
|
|
42
|
+
### STOMP delivery window
|
|
43
|
+
|
|
44
|
+
`stompSource(url, destination, collector, { prefetch: 32 })` bounds broker deliveries
|
|
45
|
+
that are waiting for acknowledgement. The default is 32; use a positive safe
|
|
46
|
+
integer to tune throughput. A zero (unlimited) window is rejected because a
|
|
47
|
+
backlog can otherwise exhaust the consumer's heap. With `manualAck: true`, the
|
|
48
|
+
collector remains responsible for settling frames. If it flushes only by count,
|
|
49
|
+
its batch must not exceed the prefetch window; a timed flush also avoids stalls.
|
package/eslint.config.js
CHANGED
|
@@ -150,6 +150,7 @@ export default [
|
|
|
150
150
|
files: ['src/modbusSource.js'],
|
|
151
151
|
rules: {
|
|
152
152
|
'max-params': ['error', 8],
|
|
153
|
+
'max-lines': ['error', { 'max': 250, 'skipBlankLines': true, 'skipComments': true }],
|
|
153
154
|
'max-lines-per-function': ['error', { 'max': 60, 'skipBlankLines': true, 'skipComments': true }],
|
|
154
155
|
'max-statements': ['error', 20]
|
|
155
156
|
}
|
package/package.json
CHANGED
package/src/modbusSource.js
CHANGED
|
@@ -124,6 +124,10 @@ function attempt(bag) {
|
|
|
124
124
|
}
|
|
125
125
|
bag.delay = bag.interval;
|
|
126
126
|
bag.state = connected(bag.client);
|
|
127
|
+
bag.client.setTimeout(bag.interval * 1000);
|
|
128
|
+
bag.client.once('close', () => {
|
|
129
|
+
bag.lost();
|
|
130
|
+
});
|
|
127
131
|
bag.source.start();
|
|
128
132
|
}).catch((err) => {
|
|
129
133
|
if (token !== bag.epoch) {
|
|
@@ -139,6 +143,28 @@ function attempt(bag) {
|
|
|
139
143
|
});
|
|
140
144
|
}
|
|
141
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Resets a live session and retries through attempt() backoff.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} bag - Mutable lifecycle bag
|
|
150
|
+
*/
|
|
151
|
+
function resume(bag) {
|
|
152
|
+
if (!bag.state.connected()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
bag.source.stop();
|
|
156
|
+
const { client } = bag;
|
|
157
|
+
bag.client = undefined;
|
|
158
|
+
bag.state = idle();
|
|
159
|
+
drop(client);
|
|
160
|
+
bag.log.error(`Connection to ${bag.target} lost, retrying in ${bag.delay}s`);
|
|
161
|
+
const handle = setTimeout(() => {
|
|
162
|
+
attempt(bag);
|
|
163
|
+
}, bag.delay * 1000);
|
|
164
|
+
bag.state = retrying(handle);
|
|
165
|
+
bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
|
|
166
|
+
}
|
|
167
|
+
|
|
142
168
|
/**
|
|
143
169
|
* Shared Modbus polling lifecycle for TCP and RTU transports.
|
|
144
170
|
*
|
|
@@ -155,9 +181,17 @@ function modbusPollingSource(options) {
|
|
|
155
181
|
maxDelay: 300,
|
|
156
182
|
delay: interval
|
|
157
183
|
};
|
|
184
|
+
bag.lost = function lost() {
|
|
185
|
+
resume(bag);
|
|
186
|
+
};
|
|
158
187
|
async function fetch() {
|
|
159
|
-
|
|
160
|
-
|
|
188
|
+
try {
|
|
189
|
+
const result = await bag.client.readHoldingRegisters(address, count);
|
|
190
|
+
return [result.data];
|
|
191
|
+
} catch (err) {
|
|
192
|
+
bag.lost();
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
161
195
|
}
|
|
162
196
|
bag.source = pollingSource(fetch, interval, collector, clk);
|
|
163
197
|
return {
|
|
@@ -179,9 +213,10 @@ function modbusPollingSource(options) {
|
|
|
179
213
|
if (bag.state.retrying()) {
|
|
180
214
|
bag.state.cancel();
|
|
181
215
|
}
|
|
182
|
-
|
|
216
|
+
const { client } = bag;
|
|
183
217
|
bag.client = undefined;
|
|
184
218
|
bag.state = idle();
|
|
219
|
+
drop(client);
|
|
185
220
|
}
|
|
186
221
|
};
|
|
187
222
|
}
|
package/src/stompSource.js
CHANGED
|
@@ -115,13 +115,13 @@ function read(message) {
|
|
|
115
115
|
* @param {object} channel - STOMP channel
|
|
116
116
|
* @param {string} destination - queue destination
|
|
117
117
|
* @param {object} collector - message collector
|
|
118
|
-
* @param {
|
|
119
|
-
* @param {boolean} manualAck - collector settles frames
|
|
118
|
+
* @param {object} delivery - serial, manualAck and prefetch settings
|
|
120
119
|
* @returns {undefined}
|
|
121
120
|
*/
|
|
122
|
-
function bind(channel, destination, collector,
|
|
121
|
+
function bind(channel, destination, collector, delivery) {
|
|
122
|
+
const { serial, manualAck, prefetch } = delivery;
|
|
123
123
|
let pending = Promise.resolve();
|
|
124
|
-
channel.subscribe({ destination, ack: 'client-individual' }, (err, message) => {
|
|
124
|
+
channel.subscribe({ destination, ack: 'client-individual', 'prefetch-count': String(prefetch) }, (err, message) => {
|
|
125
125
|
if (err) {
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
@@ -140,11 +140,13 @@ function bind(channel, destination, collector, serial, manualAck) {
|
|
|
140
140
|
* Messages are `{destination, payload, settle}`. Default ack-after-accept;
|
|
141
141
|
* with `manualAck` the collector owns settle. Arrivals enqueue in
|
|
142
142
|
* subscribe order. With `serial: false` persist may overlap after enqueue.
|
|
143
|
+
* A finite prefetch (default 32) bounds unacknowledged broker deliveries.
|
|
143
144
|
*
|
|
144
145
|
* @param {string} url - STOMP broker URL
|
|
145
146
|
* @param {string} destination - STOMP destination
|
|
146
147
|
* @param {object} collector - Collector with accept()
|
|
147
148
|
* @param {object} [options] - Connection and delivery options
|
|
149
|
+
* @param {number} [options.prefetch=32] - Positive integer delivery window
|
|
148
150
|
* @returns {object} Source with start() and stop() methods
|
|
149
151
|
*/
|
|
150
152
|
export default function stompSource(url, destination, collector, options = {}) {
|
|
@@ -157,6 +159,10 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
157
159
|
if (!collector || typeof collector.accept !== 'function') {
|
|
158
160
|
throw new Error('Collector must have an accept() method');
|
|
159
161
|
}
|
|
162
|
+
const prefetch = options.prefetch ?? 32;
|
|
163
|
+
if (!Number.isSafeInteger(prefetch) || prefetch < 1) {
|
|
164
|
+
throw new Error('prefetch must be a positive safe integer');
|
|
165
|
+
}
|
|
160
166
|
const parsed = new URL(url);
|
|
161
167
|
const serial = options.serial !== false;
|
|
162
168
|
const manualAck = options.manualAck === true;
|
|
@@ -172,7 +178,7 @@ export default function stompSource(url, destination, collector, options = {}) {
|
|
|
172
178
|
maxReconnects: -1
|
|
173
179
|
});
|
|
174
180
|
const channel = new stompit.Channel(failover);
|
|
175
|
-
bind(channel, destination, collector, serial, manualAck);
|
|
181
|
+
bind(channel, destination, collector, { serial, manualAck, prefetch });
|
|
176
182
|
state = subscribed(channel);
|
|
177
183
|
},
|
|
178
184
|
stop() {
|
|
@@ -105,6 +105,114 @@ function until(probe, limit) {
|
|
|
105
105
|
return new Promise(tick);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* TCP listener that counts accepted sockets and can drop them.
|
|
110
|
+
*
|
|
111
|
+
* @returns {Promise<object>} Gate with port, accepted, drop, close
|
|
112
|
+
*/
|
|
113
|
+
function sessionListen() {
|
|
114
|
+
let accepted = 0;
|
|
115
|
+
const sockets = new Set();
|
|
116
|
+
const server = net.createServer((sock) => {
|
|
117
|
+
accepted += 1;
|
|
118
|
+
sockets.add(sock);
|
|
119
|
+
sock.on('close', () => {
|
|
120
|
+
sockets.delete(sock);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
server.listen(0, '127.0.0.1', () => {
|
|
125
|
+
resolve({
|
|
126
|
+
port: server.address().port,
|
|
127
|
+
accepted() {
|
|
128
|
+
return accepted;
|
|
129
|
+
},
|
|
130
|
+
drop() {
|
|
131
|
+
for (const sock of sockets) {
|
|
132
|
+
sock.destroy();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
close() {
|
|
136
|
+
for (const sock of sockets) {
|
|
137
|
+
sock.destroy();
|
|
138
|
+
}
|
|
139
|
+
return new Promise((done) => {
|
|
140
|
+
server.close(done);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Builds a Modbus TCP Read Holding Registers response.
|
|
150
|
+
*
|
|
151
|
+
* @param {Buffer} req - Request frame
|
|
152
|
+
* @param {number[]} values - Register values
|
|
153
|
+
* @returns {Buffer} Response frame
|
|
154
|
+
*/
|
|
155
|
+
function holding(req, values) {
|
|
156
|
+
const tid = req.readUInt16BE(0);
|
|
157
|
+
const uid = req.readUInt8(6);
|
|
158
|
+
const bytes = values.length * 2;
|
|
159
|
+
const out = Buffer.alloc(9 + bytes);
|
|
160
|
+
out.writeUInt16BE(tid, 0);
|
|
161
|
+
out.writeUInt16BE(0, 2);
|
|
162
|
+
out.writeUInt16BE(3 + bytes, 4);
|
|
163
|
+
out.writeUInt8(uid, 6);
|
|
164
|
+
out.writeUInt8(3, 7);
|
|
165
|
+
out.writeUInt8(bytes, 8);
|
|
166
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
167
|
+
out.writeUInt16BE(values[i], 9 + i * 2);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* TCP listener that stays silent on the first socket then answers later ones.
|
|
174
|
+
*
|
|
175
|
+
* @param {number[]} values - Holding register values
|
|
176
|
+
* @returns {Promise<object>} Gate with port, accepted, close
|
|
177
|
+
*/
|
|
178
|
+
function recoverListen(values) {
|
|
179
|
+
let accepted = 0;
|
|
180
|
+
const sockets = new Set();
|
|
181
|
+
const server = net.createServer((sock) => {
|
|
182
|
+
accepted += 1;
|
|
183
|
+
sockets.add(sock);
|
|
184
|
+
sock.on('close', () => {
|
|
185
|
+
sockets.delete(sock);
|
|
186
|
+
});
|
|
187
|
+
if (accepted === 1) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
sock.on('data', (req) => {
|
|
191
|
+
if (req.length >= 12 && req.readUInt8(7) === 3) {
|
|
192
|
+
sock.write(holding(req, values));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
server.listen(0, '127.0.0.1', () => {
|
|
198
|
+
resolve({
|
|
199
|
+
port: server.address().port,
|
|
200
|
+
accepted() {
|
|
201
|
+
return accepted;
|
|
202
|
+
},
|
|
203
|
+
close() {
|
|
204
|
+
for (const sock of sockets) {
|
|
205
|
+
sock.destroy();
|
|
206
|
+
}
|
|
207
|
+
return new Promise((done) => {
|
|
208
|
+
server.close(done);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
108
216
|
describe('modbusSource', () => {
|
|
109
217
|
it('throws on empty host', () => {
|
|
110
218
|
const collector = { accept: () => {} };
|
|
@@ -311,4 +419,64 @@ describe('modbusSource', () => {
|
|
|
311
419
|
await gate.close();
|
|
312
420
|
assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
|
|
313
421
|
});
|
|
422
|
+
|
|
423
|
+
it('opens a new TCP socket after the device stays silent or drops the session', async function() {
|
|
424
|
+
this.timeout(6000);
|
|
425
|
+
const gate = await sessionListen();
|
|
426
|
+
const source = modbusSource(
|
|
427
|
+
'127.0.0.1',
|
|
428
|
+
gate.port,
|
|
429
|
+
4000,
|
|
430
|
+
3 + Math.floor(Math.random() * 8),
|
|
431
|
+
0.05,
|
|
432
|
+
{ accept() {} },
|
|
433
|
+
fakeClock(11 + Math.floor(Math.random() * 100)),
|
|
434
|
+
{ error() {} }
|
|
435
|
+
);
|
|
436
|
+
source.start();
|
|
437
|
+
await until(() => {
|
|
438
|
+
return gate.accepted() >= 1;
|
|
439
|
+
}, 2000);
|
|
440
|
+
await settle(80);
|
|
441
|
+
gate.drop();
|
|
442
|
+
await until(() => {
|
|
443
|
+
return gate.accepted() >= 2;
|
|
444
|
+
}, 3000);
|
|
445
|
+
const accepted = gate.accepted();
|
|
446
|
+
source.stop();
|
|
447
|
+
await gate.close();
|
|
448
|
+
assert.ok(accepted >= 2, 'silent or dropped session did not open a new Modbus TCP socket');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('forwards a new sample after the device answers again', async function() {
|
|
452
|
+
this.timeout(6000);
|
|
453
|
+
const count = 3 + Math.floor(Math.random() * 6);
|
|
454
|
+
const values = Array.from({ length: count }, () => {
|
|
455
|
+
return 1000 + Math.floor(Math.random() * 500);
|
|
456
|
+
});
|
|
457
|
+
const samples = [];
|
|
458
|
+
const gate = await recoverListen(values);
|
|
459
|
+
const source = modbusSource(
|
|
460
|
+
'127.0.0.1',
|
|
461
|
+
gate.port,
|
|
462
|
+
4000,
|
|
463
|
+
count,
|
|
464
|
+
0.05,
|
|
465
|
+
{
|
|
466
|
+
accept(entry) {
|
|
467
|
+
samples.push(entry);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
fakeClock(13 + Math.floor(Math.random() * 100)),
|
|
471
|
+
{ error() {} }
|
|
472
|
+
);
|
|
473
|
+
source.start();
|
|
474
|
+
await until(() => {
|
|
475
|
+
return samples.length >= 1;
|
|
476
|
+
}, 4000);
|
|
477
|
+
const sample = samples[0];
|
|
478
|
+
source.stop();
|
|
479
|
+
await gate.close();
|
|
480
|
+
assert.deepStrictEqual(sample, values, 'restored device did not deliver a new sample');
|
|
481
|
+
});
|
|
314
482
|
});
|
package/test/test_stompSource.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { describe, it } from 'mocha';
|
|
3
3
|
import stompSource from '../src/stompSource.js';
|
|
4
|
+
import stompit from 'stompit';
|
|
4
5
|
|
|
5
6
|
describe('stompSource', () => {
|
|
6
7
|
it('throws on missing url', () => {
|
|
@@ -61,3 +62,55 @@ describe('stompSource', () => {
|
|
|
61
62
|
assert.strictEqual(typeof source.start, 'function', 'manualAck source missing start');
|
|
62
63
|
});
|
|
63
64
|
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Captures the protocol headers using an inert channel.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} options - Source options
|
|
70
|
+
* @returns {object} SUBSCRIBE headers
|
|
71
|
+
*/
|
|
72
|
+
function subscription(options) {
|
|
73
|
+
const original = stompit.Channel;
|
|
74
|
+
let headers;
|
|
75
|
+
stompit.Channel = function Channel() {
|
|
76
|
+
return {
|
|
77
|
+
subscribe(value) { headers = value; },
|
|
78
|
+
close() { return undefined; }
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
const source = stompSource('stomp://localhost:61613', '/queue/backlog', {
|
|
83
|
+
accept() { return undefined; }
|
|
84
|
+
}, options);
|
|
85
|
+
source.start();
|
|
86
|
+
source.stop();
|
|
87
|
+
return headers;
|
|
88
|
+
} finally {
|
|
89
|
+
stompit.Channel = original;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('STOMP delivery window', function() {
|
|
94
|
+
it('bounds the default backlog without changing acknowledgements', function() {
|
|
95
|
+
assert.deepStrictEqual(subscription({ manualAck: true, serial: false }), {
|
|
96
|
+
destination: '/queue/backlog',
|
|
97
|
+
ack: 'client-individual',
|
|
98
|
+
'prefetch-count': '32'
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('sends a configured finite delivery window', function() {
|
|
103
|
+
const prefetch = Math.floor(Math.random() * 100) + 1;
|
|
104
|
+
assert.strictEqual(subscription({ prefetch })['prefetch-count'], String(prefetch));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const prefetch of [0, -1, 1.5, Infinity, NaN, '32']) {
|
|
108
|
+
it(`rejects invalid prefetch ${prefetch}`, function() {
|
|
109
|
+
assert.throws(function() {
|
|
110
|
+
stompSource('stomp://localhost:61613', '/queue/backlog', {
|
|
111
|
+
accept() { return undefined; }
|
|
112
|
+
}, { prefetch });
|
|
113
|
+
}, /prefetch must be a positive safe integer/u);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|