UNPKG

@qnext/iso-on-tcp

Version:

ISO-on-TCP Protocol implementation

398 lines (329 loc) 10.2 kB
//@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;