UNPKG

react-native-tcp-socket

Version:

React Native TCP socket API for Android & iOS with SSL/TLS support

513 lines (476 loc) 17.1 kB
'use strict'; import { NativeModules } from 'react-native'; import EventEmitter from 'eventemitter3'; import { Buffer } from 'buffer'; const Sockets = NativeModules.TcpSockets; import { nativeEventEmitter, getNextId } from './Globals'; /** * @typedef {"ascii" | "utf8" | "utf-8" | "utf16le" | "ucs2" | "ucs-2" | "base64" | "latin1" | "binary" | "hex"} BufferEncoding * * @typedef {import('react-native').NativeEventEmitter} NativeEventEmitter * * @typedef {{address: string, family: string, port: number}} AddressInfo * * @typedef {{localAddress: string, localPort: number, remoteAddress: string, remotePort: number, remoteFamily: string}} NativeConnectionInfo * * @typedef {{ * port: number; * host?: string; * localAddress?: string, * localPort?: number, * interface?: 'wifi' | 'cellular' | 'ethernet', * reuseAddress?: boolean, * tls?: boolean, * tlsCheckValidity?: boolean, * tlsCert?: any, * }} ConnectionOptions * * @typedef {object} ReadableEvents * @property {() => void} pause * @property {() => void} resume * * @typedef {object} SocketEvents * @property {(had_error: boolean) => void} close * @property {() => void} connect * @property {(data: Buffer | string) => void} data * @property {() => void} drain * @property {(err: Error) => void} error * @property {() => void} timeout * @property {() => void} secureConnect * * @extends {EventEmitter<SocketEvents & ReadableEvents, any>} */ export default class Socket extends EventEmitter { /** * Creates a new socket object. */ constructor() { super(); /** @package */ this._id = getNextId(); /** @private */ this._eventEmitter = nativeEventEmitter; /** @type {EventEmitter<'written', any>} @private */ this._msgEvtEmitter = new EventEmitter(); /** @type {number} @private */ this._timeoutMsecs = 0; /** @type {number | undefined} @private */ this._timeout = undefined; /** @private */ this._encoding = undefined; /** @private */ this._msgId = 0; /** @private */ this._lastRcvMsgId = Number.MAX_SAFE_INTEGER - 1; /** @private */ this._lastSentMsgId = 0; /** @private */ this._paused = false; /** @private */ this._resuming = false; /** @private */ this._writeBufferSize = 0; /** @private */ this._bytesRead = 0; /** @private */ this._bytesWritten = 0; /** @private */ this._connecting = false; /** @private */ this._pending = true; /** @private */ this._destroyed = false; // TODO: Add readOnly and writeOnly states /** @type {'opening' | 'open' | 'readOnly' | 'writeOnly'} @private */ this._readyState = 'open'; // Incorrect, but matches NodeJS behavior /** @type {{ id: number; data: string; }[]} @private */ this._pausedDataEvents = []; this.readableHighWaterMark = 16384; this.writableHighWaterMark = 16384; this.writableNeedDrain = false; this.localAddress = undefined; this.localPort = undefined; this.remoteAddress = undefined; this.remotePort = undefined; this.remoteFamily = undefined; this._registerEvents(); } get readyState() { return this._readyState; } get destroyed() { return this._destroyed; } get pending() { return this._pending; } get connecting() { return this._connecting; } get bytesWritten() { return this._bytesWritten; } get bytesRead() { return this._bytesRead; } get timeout() { return this._timeout; } /** * @package * @param {number} id */ _setId(id) { this._id = id; this._registerEvents(); } /** * @package * @param {NativeConnectionInfo} connectionInfo */ _setConnected(connectionInfo) { this._connecting = false; this._readyState = 'open'; this._pending = false; this.localAddress = connectionInfo.localAddress; this.localPort = connectionInfo.localPort; this.remoteAddress = connectionInfo.remoteAddress; this.remoteFamily = connectionInfo.remoteFamily; this.remotePort = connectionInfo.remotePort; } /** * @param {ConnectionOptions} options * @param {() => void} [callback] */ connect(options, callback) { const customOptions = { ...options }; // Normalize args customOptions.host = customOptions.host || 'localhost'; customOptions.port = Number(customOptions.port) || 0; this.once('connect', () => { if (callback) callback(); }); this._connecting = true; this._readyState = 'opening'; Sockets.connect(this._id, customOptions.host, customOptions.port, customOptions); return this; } /** * Sets the socket to timeout after `timeout` milliseconds of inactivity on the socket. By default `TcpSocket` do not have a timeout. * * When an idle timeout is triggered the socket will receive a `'timeout'` event but the connection will not be severed. * The user must manually call `socket.end()` or `socket.destroy()` to end the connection. * * If `timeout` is 0, then the existing idle timeout is disabled. * * The optional `callback` parameter will be added as a one-time listener for the `'timeout'` event. * * @param {number} timeout * @param {() => void} [callback] */ setTimeout(timeout, callback) { this._timeoutMsecs = timeout; if (this._timeoutMsecs === 0) { this._clearTimeout(); } else { this._resetTimeout(); } if (callback) this.once('timeout', callback); return this; } /** * @private */ _resetTimeout() { if (this._timeoutMsecs !== 0) { this._clearTimeout(); this._timeout = setTimeout(() => { this._clearTimeout(); this.emit('timeout'); }, this._timeoutMsecs); } } /** * @private */ _clearTimeout() { if (this._timeout !== undefined) { clearTimeout(this._timeout); this._timeout = undefined; } } /** * Set the encoding for the socket as a Readable Stream. By default, no encoding is assigned and stream data will be returned as `Buffer` objects. * Setting an encoding causes the stream data to be returned as strings of the specified encoding rather than as Buffer objects. * * For instance, calling `socket.setEncoding('utf8')` will cause the output data to be interpreted as UTF-8 data, and passed as strings. * Calling `socket.setEncoding('hex')` will cause the data to be encoded in hexadecimal string format. * * @param {BufferEncoding} [encoding] */ setEncoding(encoding) { this._encoding = encoding; return this; } /** * Enable/disable the use of Nagle's algorithm. When a TCP connection is created, it will have Nagle's algorithm enabled. * * Nagle's algorithm delays data before it is sent via the network. It attempts to optimize throughput at the expense of latency. * * Passing `true` for `noDelay` or not passing an argument will disable Nagle's algorithm for the socket. Passing false for noDelay will enable Nagle's algorithm. * * @param {boolean} noDelay Default: `true` */ setNoDelay(noDelay = true) { if (this._pending) { this.once('connect', () => this.setNoDelay(noDelay)); return this; } Sockets.setNoDelay(this._id, noDelay); return this; } /** * Enable/disable keep-alive functionality, and optionally set the initial delay before the first keepalive probe is sent on an idle socket. * * `initialDelay` is ignored. * * @param {boolean} enable Default: `false` * @param {number} initialDelay ***IGNORED**. Default: `0` */ setKeepAlive(enable = false, initialDelay = 0) { if (this._pending) { this.once('connect', () => this.setKeepAlive(enable, initialDelay)); return this; } if (initialDelay !== 0) { console.warn( 'react-native-tcp-socket: initialDelay param in socket.setKeepAlive() is ignored' ); } Sockets.setKeepAlive(this._id, enable, Math.floor(initialDelay)); return this; } /** * Returns the bound `address`, the address `family` name and `port` of the socket as reported * by the operating system: `{ port: 12346, family: 'IPv4', address: '127.0.0.1' }`. * * @returns {AddressInfo | {}} */ address() { if (!this.localAddress) return {}; return { address: this.localAddress, family: this.remoteFamily, port: this.localPort }; } /** * Half-closes the socket. i.e., it sends a FIN packet. It is possible the server will still send some data. * * @param {string | Buffer | Uint8Array} [data] * @param {BufferEncoding} [encoding] */ end(data, encoding) { if (data) { this.write(data, encoding, () => { Sockets.end(this._id); }); return this; } if (this._pending || this._destroyed) return this; this._clearTimeout(); Sockets.end(this._id); return this; } /** * Ensures that no more I/O activity happens on this socket. Destroys the stream and closes the connection. */ destroy() { if (this._destroyed) return this; this._destroyed = true; this._clearTimeout(); Sockets.destroy(this._id); return this; } /** * Sends data on the socket. The second parameter specifies the encoding in the case of a string — it defaults to UTF8 encoding. * * Returns `true` if the entire data was flushed successfully to the kernel buffer. Returns `false` if all or part of the data * was queued in user memory. `'drain'` will be emitted when the buffer is again free. * * The optional callback parameter will be executed when the data is finally written out, which may not be immediately. * * @param {string | Buffer | Uint8Array} buffer * @param {BufferEncoding} [encoding] * @param {(err?: Error) => void} [cb] * * @return {boolean} */ write(buffer, encoding, cb) { if (this._pending || this._destroyed) throw new Error('Socket is closed.'); const generatedBuffer = this._generateSendBuffer(buffer, encoding); this._writeBufferSize += generatedBuffer.byteLength; const currentMsgId = this._msgId; this._msgId = (this._msgId + 1) % Number.MAX_SAFE_INTEGER; const msgEvtHandler = (/** @type {{id: number, msgId: number, err?: string}} */ evt) => { const { msgId, err } = evt; if (msgId === currentMsgId) { this._msgEvtEmitter.removeListener('written', msgEvtHandler); this._writeBufferSize -= generatedBuffer.byteLength; this._lastRcvMsgId = msgId; this._resetTimeout(); if (this.writableNeedDrain && this._lastSentMsgId === msgId) { this.writableNeedDrain = false; this.emit('drain'); } if (cb) { if (err) cb(new Error(err)); else cb(); } } }; // Callback equivalent with better performance this._msgEvtEmitter.on('written', msgEvtHandler, this); const ok = this._writeBufferSize < this.writableHighWaterMark; if (!ok) this.writableNeedDrain = true; this._lastSentMsgId = currentMsgId; this._bytesWritten += generatedBuffer.byteLength; Sockets.write(this._id, generatedBuffer.toString('base64'), currentMsgId); return ok; } /** * Pauses the reading of data. That is, `'data'` events will not be emitted. Useful to throttle back an upload. */ pause() { if (this._paused) return; this._paused = true; Sockets.pause(this._id); this.emit('pause'); } /** * Resumes reading after a call to `socket.pause()`. */ resume() { if (!this._paused) return; this._paused = false; this.emit('resume'); this._recoverDataEventsAfterPause(); } ref() { console.warn('react-native-tcp-socket: Socket.ref() method will have no effect.'); } unref() { console.warn('react-native-tcp-socket: Socket.unref() method will have no effect.'); } /** * @private */ async _recoverDataEventsAfterPause() { if (this._resuming) return; this._resuming = true; while (this._pausedDataEvents.length > 0) { // Concat all buffered events for better performance const buffArray = []; let readBytes = 0; let i = 0; for (; i < this._pausedDataEvents.length; i++) { const evtData = Buffer.from(this._pausedDataEvents[i].data, 'base64'); readBytes += evtData.byteLength; if (readBytes <= this.readableHighWaterMark) { buffArray.push(evtData); } else { const buffOffset = this.readableHighWaterMark - readBytes; buffArray.push(evtData.slice(0, buffOffset)); this._pausedDataEvents[i].data = evtData.slice(buffOffset).toString('base64'); break; } } // Generate new event with the concatenated events const evt = { id: this._pausedDataEvents[0].id, data: Buffer.concat(buffArray).toString('base64'), }; // Clean the old events this._pausedDataEvents = this._pausedDataEvents.slice(i); this._onDeviceDataEvt(evt); if (this._paused) { this._resuming = false; return; } } this._resuming = false; Sockets.resume(this._id); } /** * @private */ _onDeviceDataEvt = (/** @type {{ id: number; data: string; }} */ evt) => { if (evt.id !== this._id) return; this._resetTimeout(); if (!this._paused) { const bufferData = Buffer.from(evt.data, 'base64'); this._bytesRead += bufferData.byteLength; const finalData = this._encoding ? bufferData.toString(this._encoding) : bufferData; this.emit('data', finalData); } else { // If the socket is paused, save the data events for later this._pausedDataEvents.push(evt); } }; /** * @private */ _registerEvents() { this._unregisterEvents(); this._dataListener = this._eventEmitter.addListener('data', this._onDeviceDataEvt); this._errorListener = this._eventEmitter.addListener('error', (evt) => { if (evt.id !== this._id) return; this.destroy(); this.emit('error', evt.error); }); this._closeListener = this._eventEmitter.addListener('close', (evt) => { if (evt.id !== this._id) return; this._setDisconnected(); this.emit('close', evt.error); }); this._connectListener = this._eventEmitter.addListener('connect', (evt) => { if (evt.id !== this._id) return; this._setConnected(evt.connection); this.emit('connect'); }); this._writtenListener = this._eventEmitter.addListener('written', (evt) => { if (evt.id !== this._id) return; this._msgEvtEmitter.emit('written', evt); }); } /** * @package */ _unregisterEvents() { this._dataListener?.remove(); this._errorListener?.remove(); this._closeListener?.remove(); this._connectListener?.remove(); this._writtenListener?.remove(); } /** * @private * @param {string | Buffer | Uint8Array} buffer * @param {BufferEncoding} [encoding] */ _generateSendBuffer(buffer, encoding) { if (typeof buffer === 'string') { return Buffer.from(buffer, encoding); } else if (Buffer.isBuffer(buffer)) { return buffer; } else if (buffer instanceof Uint8Array || Array.isArray(buffer)) { return Buffer.from(buffer); } else { throw new TypeError( `Invalid data, chunk must be a string or buffer, not ${typeof buffer}` ); } } /** * @private */ _setDisconnected() { this._unregisterEvents(); } }