@fluent-org/logger
Version:
A node fluent protocol compatible logger
641 lines • 22.2 kB
JavaScript
"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