@crussell52/socket-ipc
Version:
An event-driven IPC implementation using unix file sockets.
493 lines (424 loc) • 18.9 kB
JavaScript
/**
* @author Chris Russell <crussell52@gmail.com>
* @copyright Chris Russell 2018
* @license MIT
*/
'use strict';
const {EventEmitter} = require('events');
const net = require('net');
const fs = require('fs');
const jsonTranscoder = require('./lib/transcoder/jsonTranscoder');
const {MessageError, SendAfterCloseError, BadClientError, NoServerError} = require('./lib/error');
const uuid = require('uuid/v4');
/**
* @interface MessageWrapper
*
* @property {string} topic - A non-empty topic for the message.
* @property {*} message - The message. This may be any value that can be encoded by the current Transcoder. Unless a
* custom Transcoder is configured, this must be a JSON serializable object.
*/
/**
* @interface Transcoder
*
* @property {string} socketEncoding - The encoding to use when reading/writing data on the underlying socket.
* @property {encoderFactoryFunc} createEncoder - A no-argument factory function which returns an `encoderFunc`. The
* returned `encoderFunc` may be used to encode across multiple connections making it impossible to predict which
* encoder instance is used for which connection. For this reason, attempting to make an encoder which "buffers"
* data from several messages before sending it will result in undefined behavior.
* @property {decoderFactoryFunc} createDecoder - A no-argument factory function which returns a `decoder`. Each unique
* connection is guaranteed to receive its own decoder instance. In most cases, each returned decoder should
* contain some sort of stateful "buffer" to handle cases where a message's data is spread across multiple
* encoder calls.
*/
/**
* @callback encoderFactoryFunc
* @returns {encoderFunc}
*/
/**
* @callback decoderFactoryFunc
* @returns {decoderFunc}
*/
/**
* Invoked by an `encoderFunc` when it has finished its work.
*
* The `EncoderFunc` MUST invoke this callback when it is done working with either the first or second argument
* populated.
*
* @callback encodedCallback
* @param {Error, EncodeError} error - If an error occurred, an `Error` should be passed as the first arg.
* @param {*} data - The encoded data. The encoder MAY use `null` as a data value to indicate that the message was
* skipped; no data will be sent in this case and the message will be silently discarded. The return value must
* be ready to be written to the `net.Socket` so it should be in agreement with the socket encoding set by
* `Transcoder#socketEncoding`.
*/
/**
* Invoked by an `decoderFunc` when it has finished its work.
*
* The `EncoderFunc` MUST invoke this callback when it is done working with either the first or second argument
* populated.
*
* @callback decodedCallback
* @param {Error, DecodeError} error - If an error occurred, an `Error` should be passed as the first arg.
* @param {MessageWrapper[]} data - An array `MessageWrapper` objects that each include a single message and its topic.
* This may be an empty array in cases where the `decoderFunc` did not receive enough data for a complete message.
*/
/**
* Encodes a `MessageWrapper` so that it can be placed on the socket.
*
* @callback encoderFunc
* @param {MessageWrapper} msgWrapper - The message to be encoded and its topic.
* @param {encodedCallback} callback - The callback to invoke after all work is complete.
*/
/**
* Decodes raw data.
*
* @callback decoderFunc
* @param {*} chunk - A chunk of data to be decoded. This may contain a full message, a partial message, or multiple
* messages. The data type will depend on the socket encoding defined by `Transcoder#socketEncoding`.
* @param {decodedCallback} callback - The callback to invoke after all work is complete.
*/
/**
* Takes in a value and make sure it looks like a reasonable socket file path.
*
* Nodejs does some auto-detection in some cases to determine what type of connection
* to make. This helps guard against misconfiguration leading to other types of
* connections.
*
* @param {*} value - The value to test
*/
const validateSocketFileOption = (value) => {
// See if the value is empty.
if (!value) {
return `Is empty`
}
// See if it looks like a port (all digits)
if (/^\d+$/.test(value)) {
return `Looks like a port`;
}
};
/**
* Factory method for listening to incoming data on an underlying socket.
*
* @param {Socket} socket - The socket to listen to.
* @param {EventEmitter} emitter - Where to emit events from.
* @param {Transcoder} transcoder - The transcoder to use.
* @param {string} [clientId] - Only relevant in the server context. The id of the client the socket is attached to.
*/
const attachDataListener = (socket, emitter, transcoder, clientId) => {
/** @type {decoderFunc} */
const decoder = transcoder.createDecoder();
const emitError = (err) => {
socket.destroy(err);
emitter.emit('error', err, clientId);
};
const emitMessage = (msgWrapper) => {
emitter.emit('message', msgWrapper.message, msgWrapper.topic, clientId);
emitter.emit(`message.${msgWrapper.topic}`, msgWrapper.message, clientId);
};
socket.on('data', chunk => {
// Run the decoder with a callback that either emits an error or messages.
decoder(chunk, (err, msgWrappers) => {
if (err) {
emitError(err);
return;
}
for (let i = 0; i < msgWrappers.length; i++) {
try {
emitMessage(msgWrappers[i]);
} catch (err) {
// Emit the error and stop processing messages.
emitError(err);
return;
}
}
});
});
};
/**
* Helper function for getting a random int between two values.
*/
function getRandomIntInclusive(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min; //The maximum is inclusive and the minimum is inclusive
}
class Server extends EventEmitter {
/**
* @param {string} options.socketFile - Path to the socket file to use.
* @param {Transcoder} [options.transcoder] - The transcoder to use to prepare messages to be written to the
* underlying socket or to process data being read from the underlying socket.
*/
constructor(options) {
super();
this._transcoder = options.transcoder || jsonTranscoder;
this._encoder = this._transcoder.createEncoder();
// See if the given socket file looks like a port. We don't support running the server on a port.
let invalidSockFileReason = validateSocketFileOption(options.socketFile);
if (invalidSockFileReason) {
throw new Error(`Invalid value for 'options.socketFile' (${options.socketFile}): ${invalidSockFileReason}`);
}
this._socketFile = options.socketFile;
// In the socket map, the keys are the sockets and the values are the client id. This allows incoming messages
// to be easily associated with their client id.
this._sockets = new Map();
// In the socket lookup, the ids are the keys and the sockets are the value. This allows an application
// to send a message to a particular client by just knowing the client id.
this._clientLookup = new Map();
}
/**
* Creates a standard Node net server and immediately starts listening to the provided socket file.
*
* This method may only be called once.
*/
listen() {
if (this._server) {
throw new Error('Can not listen twice.');
}
// Create the server.
this._server = net.createServer();
this._server.on('error', err => {
if (err.code === 'EADDRINUSE') {
// See if it is a valid server by trying to connect to it.
const testSocket = net.createConnection({path: this._socketFile});
// If the connection is established, then there is an active server and the originl
// error stands.
testSocket.on('connect', () => this.emit('error', err));
// If the connection errors out, then there is a chance we can recover.
testSocket.on('error', testErr => {
if (testErr.code !== 'ECONNREFUSED') {
// We didn't connect, but it does NOT look like its because it is a dead sock file.
// Let the original error stand.
this.emit('error', err);
return;
}
// conn-refused implies that this is a dead sock file. Attempt to unlink it.
try {
fs.unlinkSync(this._socketFile);
} catch (unlinkErr) {
// Nope... unlink failed. Possibly because we don't have the perms to remove the sock.
// Emit the original error.
this.emit('error', err);
return;
}
// Try listening again.
this._server.listen(this._socketFile);
});
} else {
this.emit('error', err);
}
});
this._server.on('close', () => {
this.emit('closed'); // TODO-1.0: Remove
this.emit('close');
});
this._server.on('listening', () => {
this.emit('listening');
});
this._server.on('connection', socket => {
const id = uuid();
this._sockets.set(socket, id);
this._clientLookup.set(id, socket);
const forgetClient = () => {
// "Forget" about this client"
this._sockets.delete(socket);
this._clientLookup.delete(id);
};
socket.setEncoding(this._transcoder.socketEncoding);
// Forget the client on both end and close.
socket.on('end', forgetClient);
socket.on('close', forgetClient);
this.emit('connection', id, socket);
// Listen for messages on the socket.
attachDataListener(socket, this, this._transcoder, id);
});
this._server.listen(this._socketFile);
}
close() {
// A second close does nothing.
if (this._closeCalled) {
return;
}
// Close the server to stop incoming connections, then end all known sockets.
this._closeCalled = true;
this._server.close();
this._sockets.forEach((id, socket) => {
socket.end();
});
}
broadcast(topic, message) {
// Refuse if close has been called.
if (this._closeCalled) {
this.emit(`error`,
new SendAfterCloseError(`Can not '.broadcast()' after '.close()'`, message, topic));
return;
}
// Encode it once.
this._encoder({topic, message}, (err, data) => {
if (err) {
this.emit('error', err);
} else {
// Broadcast the message to all known sockets.
this._sockets.forEach((id, socket) => {
socket.write(data)
});
}
});
}
send(topic, message, clientId) {
// Refuse if close has been called.
if (this._closeCalled) {
this.emit(`error`,
new SendAfterCloseError(`Can not '.send()' after '.close()'`, message, topic));
return;
}
// Refuse if we don't recognize the client id. This could be because it never existed or because the client
// disconnected.
if (!this._clientLookup.has(clientId)) {
this.emit(`error`, new BadClientError(`Invalid client id: ${clientId}`, message, topic, clientId));
return;
}
// Get the socket and send data to it.
const socket = this._clientLookup.get(clientId);
this._encoder({topic, message}, (err, data) => {
if (err) {
this.emit('error', err);
} else {
socket.write(data);
}
});
}
}
class Client extends EventEmitter {
/**
* @param {Transcoder} [options.transcoder] - The transcoder to use to prepare messages to be written to the
* underlying socket or to process data being read from the underlying socket.
* @param {int|{min:{int}, max:{int}}} [options.retryDelay=1000] - If an integer, this is the number of milliseconds
* to wait between connection attempts. If an object then each delay will delayed by a random value between the
* `min` and `max` value.
* @param {int|{min:{int}, max:{int}}} [options.reconnectDelay=100] - If an integer, this is the number of
* milliseconds before trying to reconnect. If an object then each delay will be a random value between the `min`
* and `max` value.
* @param {string} options.socketFile - The path to the socket file to use.
*/
constructor(options) {
super();
this._transcoder = options.transcoder || jsonTranscoder;
this._encoder = this._transcoder.createEncoder();
// See if the given socket file looks like a port. We don't support running the server on a port.
let invalidSockFileReason = validateSocketFileOption(options.socketFile);
if (invalidSockFileReason) {
throw new Error(`Invalid value for 'options.socketFile' (${options.socketFile}): ${invalidSockFileReason}`);
}
this._socketFile = options.socketFile;
const retryDelayOpt = options.retryDelay || 1000;
if (Number.isInteger(retryDelayOpt)) {
this._retryDelay = {min: retryDelayOpt, max: retryDelayOpt}
} else {
this._retryDelay = {
min: retryDelayOpt.min || 1000,
max: retryDelayOpt.max || 1000
}
}
const reconDelayOpt = options.reconnectDelay || 1000;
if (Number.isInteger(reconDelayOpt)) {
this._reconnectDelay = {min: reconDelayOpt, max: reconDelayOpt}
} else {
this._reconnectDelay = {
min: reconDelayOpt.min || 100,
max: reconDelayOpt.max || 100
}
}
}
connect() {
// Only allow a single call to connect()
if (this._connectCalled) {
throw new Error('ipc.Client.connect() already called.');
}
this._connectCalled = true;
this._connect(false);
}
_connect(isReconnect) {
const socket = net.createConnection({path: this._socketFile});
socket.setEncoding(this._transcoder.socketEncoding);
// Until a connection is established, handle errors as connection errors.
const handleConnectError = (err) => {
this.emit('connectError', err);
const retryDelay = getRandomIntInclusive(this._retryDelay.min, this._retryDelay.max);
this._retryTimeoutId = setTimeout(() => this._connect(isReconnect), retryDelay);
};
socket.on('error', handleConnectError);
socket.on('connect', () => {
this._socket = socket;
// Always emit a connect event. Conditionally, also emit a reconnect event.
this.emit('connect', socket);
if (isReconnect) {
this.emit('reconnect', socket);
}
// Swap out the connection error handling for standard error handling.
socket.removeListener('error', handleConnectError);
socket.on('error', (err) => this.emit('error', err)); // Just repeat socket errors
// As soon as the socket emits an end event, we "forget" about the socket so that no more messages
// can be sent to it. However, anything in the buffer may still be until we hear the `close` event.
socket.on('end', () => {
this._socket = null;
});
// We don't start reconnection logic until the socket finishes closing. This makes sure that any messages
// previously put into the buffer get time to flush before we start putting more data on the wire.
socket.on('close', () => {
// Make sure we have "forgotten" the socket. This helps cases where `close` happens without `end` which
// seems to happen in abrupt disconnect scenarios.
this._socket = null;
// See if this was an explicit close.
if (this._explicitClose) {
// Emit the "closed" event.
this.emit('closed'); // TODO-1.0: Remove
this.emit('close');
} else {
// Announce the disconnect, then try to reconnect after a configured delay.
this.emit('disconnect');
const reconnectDelay = getRandomIntInclusive(this._reconnectDelay.min, this._reconnectDelay.max);
this._reconnectDelayTimeoutId = setTimeout(() => this._connect(true), reconnectDelay);
}
});
});
// Listen for data on the socket.
attachDataListener(socket, this, this._transcoder);
}
close() {
this._explicitClose = true;
// Stop any retry or reconnect timers.
clearTimeout(this._retryTimeoutId);
clearTimeout(this._reconnectDelayTimeoutId);
if (this._socket) {
this._socket.end();
} else {
// No underlying socket, so no close event to proxy. Emit it manually.
this.emit('close')
}
}
send(topic, message) {
// Refuse to send once client was explicitly closed.
if (this._explicitClose) {
this.emit('error', new SendAfterCloseError(`Can not 'send()' after 'close()'.`, message, topic));
return;
}
// Refuse to send if we don't have an active connection.
if (!this._socket) {
this.emit('error', new NoServerError(`Can not send, no active server connection.`, message, topic))
return;
}
// Encode, then write to the socket if everything went okay.
this._encoder({topic, message}, (err, data) => {
if (err) {
this.emit('error', err);
} else {
this._socket.write(data);
}
});
}
}
module.exports = {
Client,
Server,
MessageError
};