UNPKG

@fluent-org/logger

Version:
641 lines 22.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FluentSocket = exports.CloseState = exports.FluentSocketEvent = void 0; const net = require("net"); const tls = require("tls"); const events_1 = require("events"); const error_1 = require("./error"); const protocol = require("./protocol"); const stream_1 = require("stream"); var SocketState; (function (SocketState) { /** * In this state, the socket doesn't exist, and we can't read or write from it. * * No read * No write * Transitions to CONNECTING on call to connect() (potentially from maybeReconnect()) */ SocketState[SocketState["DISCONNECTED"] = 0] = "DISCONNECTED"; /** * In this state we're working on making the connection (opening socket + TLS negotiations if any), but haven't finished. * * No read * No write * Transitions to DISCONNECTED on error * Transitions to CONNECTED on success */ SocketState[SocketState["CONNECTING"] = 1] = "CONNECTING"; /** * In this state, we're doing some preparatory work before accepting writes * * Internal read * Internal write * Transitions to DISCONNECTED on soft error (will reconnect) * Transitions to DISCONNECTING on close + medium error * Transitions to FATAL on hard error */ SocketState[SocketState["CONNECTED"] = 2] = "CONNECTED"; /** * In this state, we're fully open, and able to read and write to the socket * * Can read * Can write * Transitions to DISCONNECTED on soft error (will reconnect) * Transitions to DISCONNECTING on close + medium error * Transitions to FATAL on hard error * Tansitions to DRAINING when socket.write returns false (buffer full) * Transitions to IDLE on timeout */ SocketState[SocketState["ESTABLISHED"] = 3] = "ESTABLISHED"; /** * In this state, the socket has blocked writes, as the kernel buffer is full. * See [net.Socket.write](https://nodejs.org/api/net.html#net_socket_write_data_encoding_callback) for more info. * * Can read * No write * Transitions to ESTABLISHED on drain event * Transitions to DISCONNECTED on soft error (will reconnect) * Transitions to DISCONNECTING on close + medium error * Transitions to FATAL on hard error */ SocketState[SocketState["DRAINING"] = 4] = "DRAINING"; /** * In this state, the socket is being closed, and will not be reconnected, either as the result of user action, or an event. * * Can read * No write * Transitions to DISCONNECTED on close event */ SocketState[SocketState["DISCONNECTING"] = 5] = "DISCONNECTING"; /** * In this state, the socket is being destroyed due to an error event, and will be reconnected. * * Can read * No write * Transitions to DISCONNECTED on close event */ SocketState[SocketState["CLOSING"] = 6] = "CLOSING"; /** * In this state, the socket has timed out due to inactivity. It will be reconnected once the user calls `writable()`. * * We don't auto reconnect from this state, as the idle timeout indicates low event activity. * It can also potentially indicate a misconfiguration where the timeout is too low. * * No read * No write * Transitions to CONNECTING on call to connect() (potentially from writable()) */ SocketState[SocketState["IDLE"] = 7] = "IDLE"; /** * In this state, the socket has run into a fatal error, which it believes there is no point in reconnecting. * * This is almost always a configuration misconfiguration, for example the server requires auth, but the client has no auth information. * * No read * No write * Does not transition */ SocketState[SocketState["FATAL"] = 8] = "FATAL"; })(SocketState || (SocketState = {})); var FluentSocketEvent; (function (FluentSocketEvent) { /** * Emitted once the socket has successfully been opened to the upstream server * * Provides no arguments */ FluentSocketEvent["CONNECTED"] = "connected"; /** * Emitted once the socket is ready to receive Messages * * Provides no arguments */ FluentSocketEvent["ESTABLISHED"] = "established"; /** * Emitted when the socket is starting to fill up the send buffer, and stops accepting writes. * See [net.Socket.write](https://nodejs.org/api/net.html#net_socket_write_data_encoding_callback) for more info. * * Provides no arguments */ FluentSocketEvent["DRAINING"] = "draining"; /** * Emit once the socket has emptied the send buffer again. * See [net.Socket 'drain'](https://nodejs.org/api/net.html#net_event_drain) for more info. * * Provides no arguments */ FluentSocketEvent["DRAINED"] = "drained"; /** * Emitted when the socket has timed out. It will be reconnected once the socket gets an attempted write (or next writable call) * * Provides no arguments */ FluentSocketEvent["TIMEOUT"] = "timeout"; /** * Emitted when the socket receives an ACK message. Mostly for internal use. */ FluentSocketEvent["ACK"] = "ack"; /** * Emitted when the socket is writable. This is emitted at the same time as the ESTABLISHED and the DRAINED events. * * Provides no arguments */ FluentSocketEvent["WRITABLE"] = "writable"; /** * Emitted when the socket receives an error. * * Provides one argument - the Error object associated with it. */ FluentSocketEvent["ERROR"] = "error"; /** * Emitted when the socket is closed for any reason. */ FluentSocketEvent["CLOSE"] = "close"; })(FluentSocketEvent = exports.FluentSocketEvent || (exports.FluentSocketEvent = {})); /** * How to close the socket */ var CloseState; (function (CloseState) { /** * Make the socket unable to reconnect */ CloseState[CloseState["FATAL"] = 0] = "FATAL"; /** * Allow the socket to reconnect automatically */ CloseState[CloseState["RECONNECT"] = 1] = "RECONNECT"; })(CloseState = exports.CloseState || (exports.CloseState = {})); const isAvailableForUserRead = (state) => { return (state === SocketState.ESTABLISHED || state === SocketState.DRAINING || state === SocketState.DISCONNECTING); }; /** * A wrapper around a Fluent Socket * * Handles connecting the socket, and manages error events and reconnection */ class FluentSocket extends events_1.EventEmitter { /** * Creates a new socket * * @param options The socket connection options */ constructor(options = {}) { super(); this.state = SocketState.DISCONNECTED; this.socket = null; this.reconnectTimeoutId = null; this.connectAttempts = 0; /** * Used so we can read from the socket through an AsyncIterable. * Protects the reader from accidentally closing the socket on errors. */ this.passThroughStream = null; if (options.path) { this.socketParams = { path: options.path }; } else { this.socketParams = { host: options.host || "localhost", port: options.port || 24224, }; } this.timeout = options.timeout || 3000; this.tlsEnabled = !!options.tls; this.tlsOptions = options.tls || {}; this.reconnectEnabled = !options.disableReconnect; this.writableWhenDraining = !options.notWritableWhenDraining; this.reconnect = { backoff: 2, delay: 500, minDelay: -Infinity, maxDelay: +Infinity, ...(options.reconnect || {}), }; } /** * Connects the socket to the upstream server * * Can throw a connection error, we may reconnect from this state, but the error will still be thrown * * @returns void */ async connect() { if (this.state === SocketState.FATAL) { throw new error_1.FatalSocketError("Socket is fatally closed, create a new socket to reconnect"); } if (this.state !== SocketState.DISCONNECTED && this.state !== SocketState.IDLE) { if (this.state === SocketState.DISCONNECTING) { // Try again once the socket has fully closed await new Promise(resolve => this.once(FluentSocketEvent.CLOSE, resolve)); return await this.connect(); } else { // noop, we're connected return; } } if (this.socket === null) { // If we're reconnecting early, then cancel the timeout if (this.reconnectTimeoutId !== null) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; } await this.openSocket(); } else if (!this.socket.writable) { await this.disconnect(); await this.connect(); } } /** * May reconnect the socket * @returns void */ maybeReconnect() { if (!this.reconnectEnabled || this.reconnectTimeoutId !== null) { return; } if (this.state !== SocketState.DISCONNECTED) { // Socket is connected or in a fatal state or idle return; } // Exponentially back off based on this.connectAttempts const reconnectInterval = Math.min(this.reconnect.maxDelay, Math.max(this.reconnect.minDelay, this.reconnect.backoff ** this.connectAttempts * this.reconnect.delay)); this.reconnectTimeoutId = setTimeout(() => { this.reconnectTimeoutId = null; // Ignore errors if there are any this.connect().catch(() => { }); }, reconnectInterval); } /** * Creates a new TLS socket * @returns A new socket to use for the connection */ createTlsSocket() { let opts = { ...this.tlsOptions, ...this.socketParams }; if (this.timeout >= 0) { opts = { ...opts, timeout: this.timeout }; } return tls.connect(opts); } /** * Creates a new TCP socket * @returns A new socket to use for the connection */ createTcpSocket() { let opts = this.socketParams; if (this.timeout >= 0) { opts = { ...opts, timeout: this.timeout }; } return net.createConnection(opts); } /** * Returns a new socket * * @param onConnect Called once the socket is connected * @returns */ createSocket(onConnect) { if (this.tlsEnabled) { const socket = this.createTlsSocket(); socket.once("secureConnect", onConnect); return socket; } else { const socket = this.createTcpSocket(); socket.once("connect", onConnect); return socket; } } /** * Sets up and connects the socket * * @returns A promise which resolves once the socket is connected, or once it is errored */ openSocket() { this.state = SocketState.CONNECTING; this.socket = this.createSocket(() => this.handleConnect()); this.socket.on("error", err => this.handleError(err)); this.socket.on("timeout", () => this.handleTimeout()); this.socket.on("close", () => this.handleClose()); this.socket.on("drain", () => this.handleDrain()); // Pipe through a passthrough stream before passing to msgpack // This prevents error events on the socket from affecting the decode pipeline this.passThroughStream = new stream_1.PassThrough(); this.socket.pipe(this.passThroughStream); this.processMessages(protocol.decodeServerStream(this.passThroughStream)); return new Promise((resolve, reject) => { const onConnected = () => { resolve(); // Avoid a memory leak and remove the other listener this.removeListener(FluentSocketEvent.ERROR, onError); }; const onError = (err) => { reject(err); // Avoid a memory leak and remove the other listener this.removeListener(FluentSocketEvent.CONNECTED, onConnected); }; this.once(FluentSocketEvent.CONNECTED, onConnected); this.once(FluentSocketEvent.ERROR, onError); }); } /** * Called once the socket is connected */ handleConnect() { this.connectAttempts = 0; this.state = SocketState.CONNECTED; this.emit(FluentSocketEvent.CONNECTED); this.onConnected(); } /** * Processes messages from the socket * * @param iterable The socket read data stream * @returns Promise for when parsing completes */ async processMessages(iterable) { try { for await (const message of iterable) { this.onMessage(message); } } catch (e) { this.close(CloseState.RECONNECT, e); } } /** * Called from an error event on the socket */ handleError(error) { if (error.code === "ECONNRESET") { // This is OK in disconnecting states if (this.state === SocketState.DISCONNECTING || this.state === SocketState.IDLE || this.state === SocketState.DISCONNECTED) { return; } } this.onError(error); } /** * Called when the socket times out * Should suspend the socket (set it to IDLE) */ handleTimeout() { if (this.socket !== null) { this.state = SocketState.IDLE; this.socket.end(() => this.emit(FluentSocketEvent.TIMEOUT)); } else { this.close(CloseState.FATAL, new error_1.SocketTimeoutError("Socket timed out, but socket wasn't open")); } } /** * Called from a "close" event on the socket * * Should clean up the state, and potentially trigger a reconnect */ handleClose() { var _a; if (this.state === SocketState.CONNECTING) { // If we never got to the CONNECTED stage // Prevents us from exponentially retrying configuration errors this.connectAttempts += 1; } this.socket = null; // Make sure the passthrough stream is closed (_a = this.passThroughStream) === null || _a === void 0 ? void 0 : _a.end(); this.passThroughStream = null; let triggerReconnect = false; // Only try to reconnect if we had an didn't expect to disconnect or hit a fatal error if (this.state !== SocketState.FATAL && this.state !== SocketState.IDLE) { if (this.state !== SocketState.DISCONNECTING) { triggerReconnect = true; } this.state = SocketState.DISCONNECTED; } this.onClose(); if (triggerReconnect) { this.maybeReconnect(); } } /** * Called when the socket has fully drained, and the buffers are free again */ handleDrain() { // We may not have noticed that we were draining, or we may have moved to a different state in the mean time if (this.state === SocketState.DRAINING) { this.state = SocketState.ESTABLISHED; this.emit(FluentSocketEvent.DRAINED); this.onWritable(); } } /** * Handles a connection event on the connection * * Called once a connection is established */ onConnected() { this.onEstablished(); } /** * Called once a connection is ready to accept writes externally */ onEstablished() { this.state = SocketState.ESTABLISHED; this.emit(FluentSocketEvent.ESTABLISHED); this.onWritable(); } /** * Called once we think socket.writable() will return true * Note that this event doesn't guarantee that socket.writable() will return true, * for example, the server might disconnect in between emitting the event and attempting a write. */ onWritable() { this.emit(FluentSocketEvent.WRITABLE); } /** * Handles an error event on the connection * * @param error The error */ onError(error) { this.emit(FluentSocketEvent.ERROR, error); } /** * Handles a close event from the socket */ onClose() { this.emit(FluentSocketEvent.CLOSE); } // This is the EventEmitter signature // eslint-disable-next-line @typescript-eslint/no-explicit-any emit(event, ...args) { if (this.listenerCount(event) > 0) { return super.emit(event, ...args); } else { return false; } } /** * Handles a message from the server * * @param message The decoded message */ onMessage(message) { if (isAvailableForUserRead(this.state)) { if (protocol.isAck(message)) { this.onAck(message.ack); } else if (protocol.isHelo(message)) { this.close(CloseState.FATAL, new error_1.AuthError("Server expected authentication, but client didn't provide any, closing")); } else { this.close(CloseState.FATAL, new error_1.UnexpectedMessageError("Received unexpected message")); } } else { this.close(CloseState.FATAL, new error_1.UnexpectedMessageError("Received unexpected message")); } } /** * Handle an ack from the server * * @param chunkId The chunk from the ack event */ onAck(chunkId) { this.emit(FluentSocketEvent.ACK, chunkId); } /** * Gracefully closes the connection * * Changes state to DISCONNECTING, meaning we don't reconnect from this state */ disconnect() { return new Promise(resolve => { if (this.reconnectTimeoutId !== null) { clearTimeout(this.reconnectTimeoutId); this.reconnectTimeoutId = null; } if (this.socket !== null) { this.state = SocketState.DISCONNECTING; this.socket.end(resolve); } else { resolve(); } }); } /** * Forcefully closes the connection, and optionally emits an error * * Changes state to DISCONNECTING, meaning we don't reconnect from this state * @param closeState The state to close this socket in * @param error The error that closed the socket */ close(closeState, error) { if (this.socket !== null) { if (closeState === CloseState.FATAL) { this.state = SocketState.FATAL; } else { this.state = SocketState.CLOSING; } this.socket.destroy(); } if (error) { this.onError(error); } } /** * Check if the socket is writable * * Will terminate the socket if it is half-closed * * Will connect the socket if it is disconnected * @returns If the socket is in a state to be written to */ socketWritable() { // Accept CONNECTED and ESTABLISHED as writable states if (this.socket === null || (this.state !== SocketState.ESTABLISHED && this.state !== SocketState.CONNECTED)) { // Resume from idle state if (this.state === SocketState.IDLE) { // Ignore errors if there are any this.connect().catch(() => { }); } return false; } // Check if the socket is writable if (!this.socket.writable) { this.close(CloseState.RECONNECT, new error_1.SocketNotWritableError("Socket not writable")); return false; } return true; } /** * Check if the socket is writable for clients * * @returns If the socket is in a state to be written to */ writable() { return this.socketWritable() && this.state === SocketState.ESTABLISHED; } innerWrite(data) { return new Promise((resolve, reject) => { if (this.socket === null) { return reject(new error_1.SocketNotWritableError("Socket not writable")); } const keepWriting = this.socket.write(data, err => { if (err) { reject(err); } else { resolve(); } }); if (!keepWriting && !this.writableWhenDraining) { this.state = SocketState.DRAINING; this.emit(FluentSocketEvent.DRAINING); } }); } /** * Write data to the socket * * Fails if the socket is not writable * * @param data The data to write to the socket * @returns A Promise, which resolves when the data is successfully written to the socket, or rejects if it couldn't be written */ socketWrite(data) { if (!this.socketWritable()) { return Promise.reject(new error_1.SocketNotWritableError("Socket not writable")); } return this.innerWrite(data); } /** * Write data to the socket * * Fails if the socket is not writable * * @param data The data to write to the socket * @returns A Promise, which resolves when the data is successfully written to the socket, or rejects if it couldn't be written */ write(data) { if (!this.writable()) { return Promise.reject(new error_1.SocketNotWritableError("Socket not writable")); } return this.innerWrite(data); } } exports.FluentSocket = FluentSocket; //# sourceMappingURL=socket.js.map