UNPKG

@superhero/tcp-record-channel

Version:

TCP record channel is intended to be used as a low level bidirectional TCP socket communication server-to-server

310 lines (282 loc) 7.71 kB
import tls from 'node:tls' import net from 'node:net' import EventEmitter from 'node:events' /** * A class that provides a simple interface for transmitting and receiving * records over a TLS socket. * * @extends EventEmitter * @emits record */ export default class Channel extends EventEmitter { #buffer = new WeakMap /** * Default to ASCII Delimited Encoding. * * @param {Object} [config] * @param {string} [config.START_OF_TRANSMISSION] default: '\x02' * @param {string} [config.RECORD_SEPARATOR] default: '\x1E' * @param {string} [config.UNIT_SEPARATOR] default: '\x1F' * @param {number} [config.KEEP_ALIVE] default: 60e3 */ constructor(config) { super() config = Object.assign( { 'START_OF_TRANSMISSION' : '\x02', 'RECORD_SEPARATOR' : '\x1E', 'UNIT_SEPARATOR' : '\x1F', 'KEEP_ALIVE' : 60e3 }, config) this.config = config } /** * @see https://nodejs.org/api/tls.html#class-tlsserver * @returns {tls.Server} */ createTlsServer(config, onConnection) { const server = tls.createServer(config) server.on('secureConnection', this.onConnection.bind(this, onConnection)) return server } /** * @see https://nodejs.org/api/net.html#net_class_net_server * @returns {net.Server} */ createNetServer(config, onConnection) { const server = net.createServer(config) server.on('connection', this.onConnection.bind(this, onConnection)) return server } async onConnection(plugin, socket) { plugin && await plugin(socket) if(socket.authorized) { this.init(socket) socket.on('data', this.buffer.bind(this, socket)) socket.setKeepAlive(true, this.config.KEEP_ALIVE) socket.resume() this.#transmit(socket, this.config.START_OF_TRANSMISSION) } else { socket.destroy() } } /** * @see https://nodejs.org/api/tls.html#class-tlstlssocket * @returns {tls.TLSSocket} */ createTlsClient(config) { return this.#createClient(config, tls.connect) } /** * @see https://nodejs.org/api/net.html#net_class_net_socket * @returns {net.Socket} */ createNetClient(config) { return this.#createClient(config, net.connect) } #createClient(config, connect) { return new Promise((accept, reject) => { const socket = connect(config) this.init(socket) socket.setKeepAlive(true, this.config.KEEP_ALIVE) socket.once('close', () => { socket.removeAllListeners() socket.destroy() const error = new Error('Could not connect to server') error.code = 'E_TCP_RECORD_CHANNEL_CLIENT_CONNECT' error.cause = new Error('Connection closed before ready') error.cause.code = 'E_TCP_RECORD_CHANNEL_CLOSED_BEFORE_READY' reject(error) }) socket.once('timeout', () => { socket.removeAllListeners() socket.destroy() const error = new Error('Could not connect to server') error.code = 'E_TCP_RECORD_CHANNEL_CLIENT_CONNECT' error.cause = new Error('Connection timeout before ready') error.cause.code = 'E_TCP_RECORD_CHANNEL_TIMEOUT_BEFORE_READY' reject(error) }) socket.once('error', (reason) => { socket.removeAllListeners() socket.destroy() const error = new Error('Error when connecting to server') error.code = 'E_TCP_RECORD_CHANNEL_CLIENT_CONNECT' error.cause = reason reject(error) }) socket.once('data', (buffer) => { socket.removeAllListeners() socket.pause() if(this.config.START_OF_TRANSMISSION === buffer.toString()) { socket.on('data', this.buffer.bind(this, socket)) socket.resume() accept(socket) } else { socket.destroy() const error = new Error('Server not ready') error.code = 'E_TCP_RECORD_CHANNEL_CLIENT_CONNECT' error.cause = new Error(`Invalid ready signal: ${buffer.toString()}`) error.cause.code = 'E_TCP_RECORD_CHANNEL_INVALID_READY_SIGNAL' reject(error) } }) }) } /** * Initiate the weak map for buffered message fragments * with an empty buffer instance to prevent repeated conditions * on data processing. * * @param {tls.TLSSocket} socket * @returns {void} */ init(socket) { this.#buffer.set(socket, Buffer.from('')) } /** * Encodes a record from units. * * @param {string[]} units * @returns {Buffer} record */ encode(units) { const record = units.join(this.config.UNIT_SEPARATOR) return Buffer.from(record + this.config.RECORD_SEPARATOR) } /** * Generates the buffered units of the record once the record separator * is found in the buffer. * * @param {tls.TLSSocket} socket * @param {Buffer} buffer * @returns {Generator} */ * decode(socket, buffer) { this.#buffer.set(socket, Buffer.concat([ this.#buffer.get(socket), buffer ])) for(let index = this.#findRecordSeparator(socket); -1 !== index; index = this.#findRecordSeparator(socket)) { const buffered = this.#buffer.get(socket), record = buffered.slice(0, index), units = record.toString().split(this.config.UNIT_SEPARATOR) this.#buffer.set(socket, buffered.slice(index + 1)) yield units } } #findRecordSeparator(socket) { return this.#buffer.get(socket).indexOf(this.config.RECORD_SEPARATOR) } /** * Buffers the record and emits when a complete record is found. * * @param {tls.TLSSocket} socket * @param {Buffer} buffer * @returns {void} * @emits record */ buffer(socket, buffer) { for(const units of this.decode(socket, buffer)) { this.emit('record', units, socket) } } /** * Encodes and transmits units as a transmittable record over a TLS socket. * * @param {tls.TLSSocket} socket * @param {string[]} units * @returns {void} * @throws {Error} E_TCP_RECORD_CHANNEL_TRANSMIT */ transmit(socket, units) { const record = this.encode(units) try { this.#transmit(socket, record) } catch(error) { if('E_TCP_RECORD_CHANNEL_TRANSMIT' === error.code) { error.record = units } throw error } } /** * Encodes and transmits units as a transmittable record over all TLS sockets. * * @param {tls.TLSSocket[]} sockets * @param {string[]} units * @returns {void} * @throws {Error} E_TCP_RECORD_CHANNEL_BROADCAST */ broadcast(sockets, units) { const reasons = [], record = this.encode(units) for(const socket of sockets) { try { this.#transmit(socket, record) } catch(reason) { reasons.push(reason) } } if(reasons.length) { const error = new Error('Could not broadcast record to all sockets') error.code = 'E_TCP_RECORD_CHANNEL_BROADCAST' error.record = units error.cause = reasons throw error } } #transmit(socket, record) { if(socket.writable) { socket.write(record) } else { const error = new Error('Could not write record to socket') error.code = 'E_TCP_RECORD_CHANNEL_TRANSMIT' error.cause = 'Socket is not writable' // Define the socket property on the error object as non-enumerable. Object.defineProperty(error, 'socket', { value: socket }) throw error } } }