@qnext/iso-on-tcp
Version:
ISO-on-TCP Protocol implementation
398 lines (329 loc) • 10.2 kB
JavaScript
//@ts-check
const { Duplex } = require('stream');
const { debuglog } = require('util');
const debug = debuglog('iso-on-tcp');
const constants = require('./constants.json');
const Parser = require('./parser.js');
const Serializer = require('./serializer.js');
const CONN_DISCONNECTED = 0;
const CONN_CONNECTING = 1;
const CONN_CONNECTED = 2;
const CONN_DISCONNECTING = 3;
const CONN_FINISHED = 99;
/**
* Duplex stream that handles the lifecycle of an ISO-on-TCP connection
* as a client.
*
* @class
*/
class ISOOnTCPClient extends Duplex {
_inBuffer = [];
_outBuffer = [];
_tpduSize = 0;
_connectionState = CONN_DISCONNECTED;
_destRef = 0;
_drSent = false;
/**
*
* @param {Duplex} stream the underlying stream used to
* @param {object} [opts] options to the constructor
* @param {number} [opts.tpduSize=1024] the tpdu size. Must be a power of 2
* @param {number|string} [opts.srcTSAP=0] the source TSAP
* @param {number|string} [opts.dstTSAP=0] the destination TSAP
* @param {number} [opts.sourceRef=random] our reference. If not provided, an random one is generated
* @param {boolean} [opts.forceClose=false] skip sending Disconnect Requests on disconnecting, and forcibly closes the connection instead
* @param {(msg: object) => boolean} [opts.validateConnection] a function that will be called to validate the connection parameters.
*/
constructor(stream, opts = {}) {
debug('new ISOOnTCPClient', opts);
super();
if (!(stream instanceof Duplex)) {
throw new Error("Parameter 'stream' must be a duplex stream");
}
this.stream = stream;
this.tpduSize = this._tpduSize = opts.tpduSize || 1024;
this.srcTSAP = opts.srcTSAP || 0;
this.dstTSAP = opts.dstTSAP || 0;
this.forceClose = !!opts.forceClose;
this.validateConnection =
typeof opts.validateConnection === 'function'
? opts.validateConnection
: () => true;
this._checkTSAP(this.srcTSAP, 'srcTSAP');
this._checkTSAP(this.dstTSAP, 'dstTSAP');
if (opts.sourceRef === undefined) {
this._sourceRef = Math.floor(Math.random() * 0xffff);
} else {
this._sourceRef = opts.sourceRef;
}
this._parser = new Parser();
this._serializer = new Serializer();
this._parser.on('error', (e) => this._onParserError(e));
this._serializer.on('error', (e) => this._onSerializerError(e));
this._parser.on('data', (d) => this._incomingData(d));
this.stream.pipe(this._parser);
this._serializer.pipe(this.stream);
this.stream.on('error', (e) => this._onStreamError(e));
this.stream.on('close', () => this._onStreamClose());
this.stream.on('end', () => this._onStreamEnd());
}
/**
* our reference code
*/
get sourceReference() {
return this._sourceRef;
}
/**
* the destination reference if we're connected, null otherwhise
*/
get destinationReference() {
return this.isConnected ? this._destRef : null;
}
/**
* the negotiated TPDU size, being the smallest of ours and theirs TPDU size
*/
get negotiatedTpduSize() {
return this._tpduSize;
}
/**
* whether we're currently connected or not
*/
get isConnected() {
return this._connectionState === CONN_CONNECTED;
}
_onStreamError(e) {
debug('ISOOnTCPClient _onStreamError', e);
this.emit('error', e);
this._destroy();
}
_onStreamClose() {
debug('ISOOnTCPClient _onStreamClose');
this.push(null); //signalizes end of read stream, emits 'end' event
this._destroy();
}
_onStreamEnd() {
debug('ISOOnTCPClient _onStreamEnd');
this.push(null); //signalizes end of read stream, emits 'end' event
this._destroy();
}
_onParserError(e) {
debug('ISOOnTCPClient _onParserError', e);
this.emit('error', e);
this._destroy();
}
_onSerializerError(e) {
debug('ISOOnTCPClient _onSerializerError', e);
this.emit('error', e);
this._destroy();
}
_checkTSAP(value, name) {
if (!value) {
return;
}
const value_type = typeof value;
switch (value_type) {
case 'string':
if (value.length != 8) {
throw new Error(
`${name} of type string must be of length 8, got ${value.length}`
);
}
break;
case 'number':
if (value < 0 || value > 65535) {
throw new Error(
`${name} of type number must be in [0..65535] (uint16), got ${value}`
);
}
break;
default:
throw new Error(
`${name} type must be in [string, number], got ${value_type}`
);
}
}
_incomingData(data) {
debug('ISOOnTCPClient _incomingData', data);
process.nextTick(() => this.emit('raw-message', data));
switch (data.type) {
case constants.tpdu_type.CR:
case constants.tpdu_type.CC:
if (!this.validateConnection(data)) {
debug('ISOOnTCPClient _incomingData CR-CC-not-valid');
this.close();
return;
}
this._destRef = data.source;
//negotiate tdpu size
this._tpduSize = Math.min(data.tpdu_size, this.tpduSize);
if (data.type == constants.tpdu_type.CR) {
this._serializer.write({
type: constants.tpdu_type.CC,
destination: this._destRef,
source: this._sourceRef,
tpdu_size: this._tpduSize,
srcTSAP: this.srcTSAP,
dstTSAP: this.dstTSAP,
});
}
this._connectionState = CONN_CONNECTED;
// send any queued messages
for (const buf of this._outBuffer) {
this._sendDT(buf);
}
this._outBuffer = [];
process.nextTick(() => this.emit('connect'));
break;
case constants.tpdu_type.DT:
this._inBuffer.push(data.payload);
if (data.last_data_unit) {
let res = Buffer.concat(this._inBuffer);
this._inBuffer = [];
this.emit('message', {
payload: res,
});
this.push(res);
}
break;
case constants.tpdu_type.DR:
// 0: Reason not specified
// 128: Normal disconnect initiated by the session entity
if (!(data.reason == 0 || data.reason == 128)) {
let errDescr =
constants.DR_reason[data.reason] || '<Unknown reason code>';
this.emit(
'error',
new Error(
`Received a disconnect request with reason [${data.reason}]: ${errDescr}`
)
);
}
if (!this._drSent) {
this._serializer.write({
type: constants.tpdu_type.DR,
});
}
if (this.stream.end) {
this.stream.end();
} else {
this._destroy();
}
break;
default:
}
}
_sendDT(chunk) {
//split buffer in multiple telegrams if buffer is bigger than negotiated tdpu size
let chunkArr;
if (chunk.length > this._tpduSize) {
chunkArr = [];
for (let i = 0; i < chunk.length; i += this._tpduSize) {
chunkArr.push(
chunk.slice(i, Math.min(i + this._tpduSize, chunk.length))
);
}
} else {
chunkArr = [chunk];
}
for (let i = 0; i < chunkArr.length; i++) {
this._serializer.write({
type: constants.tpdu_type.DT,
last_data_unit: i === chunkArr.length - 1,
payload: chunkArr[i],
});
}
}
_read(size) {
debug('ISOOnTCPClient _read', size);
//TODO handle backpressure
}
_write(chunk, encoding, cb) {
debug('ISOOnTCPClient _write', chunk);
if (!(chunk instanceof Buffer)) {
cb(new Error('Data must be of Buffer type'));
return;
}
if (this._connectionState > CONN_CONNECTED) {
cb(new Error("Can't write data after end"));
return;
}
// buffer the outgoing messsages until we're connected
if (this._connectionState < CONN_CONNECTED) {
debug('ISOOnTCPClient write not-connected');
this._outBuffer.push(chunk);
cb();
return;
}
this._sendDT(chunk);
cb();
}
_final(cb) {
debug('ISOOnTCPClient _finish');
this.close();
cb();
}
_destroy() {
debug('ISOOnTCPClient _destroy');
//call this only once
if (this._connectionState >= CONN_FINISHED) return;
this._connectionState = CONN_FINISHED;
function destroyStream(stream) {
debug('ISOOnTCPClient _destroy destroyStream');
if (!stream) return;
if (stream.destroy) {
stream.destroy();
} else if (stream._destroy) {
stream._destroy();
}
}
destroyStream(this._serializer);
destroyStream(this.stream);
destroyStream(this._parser);
this.emit('close');
}
// ----- public methods
/**
* Initiates the connection process
*
* @param {function} [cb] a callback that is added to the {@link ISOOnTCPClient#connect} event
* @throws an error if the client is not in a disconnected state
*/
connect(cb) {
if (this._connectionState > CONN_DISCONNECTED) {
throw new Error('Client not in disconnected state');
}
if (typeof cb === 'function') {
// @ts-ignore
this.once('connect', cb);
}
this._connectionState = CONN_CONNECTING;
this._serializer.write({
type: constants.tpdu_type.CR,
destination: this._destRef,
source: this._sourceRef,
//class: 0,
//extended_format: false,
//no_flow_control: false,
tpdu_size: this.tpduSize,
srcTSAP: this.srcTSAP,
dstTSAP: this.dstTSAP,
});
}
/**
* Closes the connection by sending a DR telegram. If forceClose was set
* to true, DR is not sent and the connection is abruptly disconnected instead
*/
close() {
debug('ISOOnTCPClient disconnect');
if (this._connectionState == CONN_CONNECTED && !this.forceClose) {
this._connectionState = CONN_DISCONNECTING;
this._serializer.write({
type: constants.tpdu_type.DR,
});
this._drSent = true;
} else {
this._destroy();
}
}
}
module.exports = ISOOnTCPClient;