pocket-messaging
Version:
A small cryptographic messaging library written in TypeScript both for browser and nodejs supporting TCP and WebSockets
660 lines (659 loc) • 26.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.once = exports.Messaging = void 0;
const eventemitter3_1 = __importDefault(require("eventemitter3"));
const crypto_1 = __importDefault(require("crypto")); // Only used for synchronous randomBytes.
const types_1 = require("./types");
const pocket_console_1 = require("pocket-console");
const console = (0, pocket_console_1.PocketConsole)({ module: "Messaging" });
class Messaging {
/**
* @param socket the underlying socket to use. Socket must be in binary mode.
* @param pingInterval in milliseconds, set to send frequent pings on the socket to detect silent disconnects.
*/
constructor(socket, pingInterval) {
/** Keep track of pending ping, pong resets it. */
this.pingTimestamp = 0;
/**
* Remove a stored pending message so that it cannot receive any more replies.
*/
this.cancelPendingMessage = (msgId) => {
delete this.pendingReply[msgId.toString("hex")];
};
/**
* See if a specific msgId is pending a reply.
* @param msgId the ID of the message to check if it is pending a reply.
* @returns true if message identified by msgId is pending a reply.
*/
this.isMessagePending = (msgId) => {
if (this.pendingReply[msgId.toString("hex")]) {
return true;
}
return false;
};
/**
* This pauses all timeouts for a message until the next message arrives then timeouts are re-activated (if set initially ofc).
* This could be useful when expecting a never ending stream of messages where chunks could be time apart.
*/
this.clearTimeout = (msgId) => {
const sentMessage = this.pendingReply[msgId.toString("hex")];
if (sentMessage) {
sentMessage.isCleared = true;
}
};
/**
* Notify all pending messages and the main emitter about an error.
*
* @param message the error message
*
*/
this.emitError = (message) => {
const eventEmitters = this.getAllEventEmitters();
const errorEvent = {
error: message
};
this.emitEvent(eventEmitters, types_1.EventType.ERROR, errorEvent);
const anyEvent = {
type: types_1.EventType.ERROR,
event: errorEvent
};
this.emitEvent(eventEmitters, types_1.EventType.ANY, anyEvent);
};
/**
* Notify all pending messages about the close.
*/
this.socketClose = (hadError) => {
if (this._isClosed) {
return;
}
this._isClosed = true;
this.disablePing();
const eventEmitters = this.getAllEventEmitters();
this.pendingReply = {}; // Remove all from memory
const closeEvent = {
hadError: Boolean(hadError)
};
this.emitEvent(eventEmitters, types_1.EventType.CLOSE, closeEvent);
const anyEvent = {
type: types_1.EventType.CLOSE,
event: closeEvent
};
this.emitEvent(eventEmitters, types_1.EventType.ANY, anyEvent);
};
/**
* Send a ping to remote to force a disconnect event in the case
* the socket has silently closed.
* There is no reply expected on the ping.
*/
this.sendPing = () => {
if (this._isClosed || this.pingInterval === 0) {
return;
}
if (this._isOpened) {
if (this.pingTimestamp > 0) {
console.debug("Pong message not received in time, closing.");
this.emitError("Messaging ping/pong timeouted, closing");
this.close();
return;
}
this.pingTimestamp = Date.now();
// Send empty ping message.
// Not expecting a traditional reply on it.
this.send(types_1.PING_ROUTE);
}
if (this.pingInterval > 0) {
this.pingTimeout = setTimeout(this.sendPing, this.pingInterval);
}
};
/**
* Buffer incoming raw data from the socket and process it.
*/
this.socketData = (data) => {
if (Buffer.isBuffer(data)) {
this.incomingQueue.chunks.push(data);
this.isBusyIn++;
this.processInqueue();
}
else {
throw new Error("Messaging does not work with text data");
}
};
this.processInqueue = async () => {
if (this.isBusyIn <= 0) {
return;
}
this.isBusyIn--;
if (!this.assembleIncoming()) {
// Bad stream, close.
this.close();
return;
}
this.dispatchIncoming();
this.processInqueue(); // In case someone increased the isBusyIn counter
};
/**
* Assemble messages from decrypted data and put to next queue.
*
*/
this.assembleIncoming = () => {
while (this.incomingQueue.chunks.length > 0) {
if (this.incomingQueue.chunks[0].length < 5) {
// Not enough data ready, see if we can collapse
if (this.incomingQueue.chunks.length > 1) {
const buf = this.incomingQueue.chunks.shift();
if (buf) {
this.incomingQueue.chunks[0] = Buffer.concat([buf, this.incomingQueue.chunks[0]]);
}
continue;
}
return true;
}
// Check version byte
const version = this.incomingQueue.chunks[0].readUInt8(0);
if (version !== 0) {
this.incomingQueue.chunks.length = 0;
console.error("Bad stream detected reading version byte.");
return false;
}
const length = this.incomingQueue.chunks[0].readUInt32LE(1);
const buffer = this.extractBuffer(this.incomingQueue.chunks, length);
if (!buffer) {
// Not enough data ready
return true;
}
let ret;
try {
ret = Messaging.DecodeHeader(buffer);
}
catch (e) {
this.incomingQueue.chunks.length = 0;
console.error("Bad stream detected in header.");
return false;
}
if (!ret) {
return false;
}
const [header, data] = ret;
const inMessage = {
target: header.target,
msgId: header.msgId,
data,
expectingReply: header.config & (types_1.ExpectingReply.SINGLE + types_1.ExpectingReply.MULTIPLE), // other config bits are reserved for future use
};
this.incomingQueue.messages.push(inMessage);
}
return true;
};
/**
* Dispatch messages on event emitters.
*
*/
this.dispatchIncoming = () => {
while (this.incomingQueue.messages.length > 0) {
if (this.dispatchLimit === 0) {
// This is corked
return;
}
else if (this.dispatchLimit > 0) {
this.dispatchLimit--;
}
else {
// Negative number means no limiting in place
// Let through
}
const inMessage = this.incomingQueue.messages.shift();
if (inMessage) {
// Note: target is not necessarily a msg ID,
// but we check if it is.
const targetMsgId = inMessage.target.toString("hex");
const pendingReply = this.pendingReply[targetMsgId];
if (pendingReply) {
pendingReply.replyCounter++;
pendingReply.isCleared = false;
if (pendingReply.stream) {
// Expecting many replies, update timeout activity timestamp.
pendingReply.timestamp = this.getNow();
}
else {
// Remove pending message if only single message is expected
this.cancelPendingMessage(pendingReply.msgId);
}
// Dispatch reply on message specific event emitter
const replyEvent = {
toMsgId: inMessage.target,
fromMsgId: inMessage.msgId,
data: inMessage.data,
expectingReply: inMessage.expectingReply
};
this.emitEvent([pendingReply.eventEmitter], types_1.EventType.REPLY, replyEvent);
const anyEvent = {
type: types_1.EventType.REPLY,
event: replyEvent
};
this.emitEvent([pendingReply.eventEmitter], types_1.EventType.ANY, anyEvent);
}
else {
// This is not a reply message (or the message was cancelled).
// Dispatch on main event emitter.
// Do alphanumric check on target string. A-Z, a-z, 0-9, ._-
if (inMessage.target.some(char => {
if (char >= 49 && char <= 57) {
return false;
}
if (char >= 65 && char <= 90) {
return false;
}
if (char >= 97 && char <= 122) {
return false;
}
if ([45, 46, 95].includes(char)) {
return false;
}
return true; // non alpha-numeric found
})) {
// Non alphanumeric found
// Ignore this message
return;
}
if (inMessage.target.toString().toLowerCase() === types_1.PING_ROUTE.toLowerCase()) {
// Send empty pong message.
// Not expecting a reply on this.
this.send(types_1.PONG_ROUTE);
return;
}
else if (inMessage.target.toString().toLowerCase() === types_1.PONG_ROUTE.toLowerCase()) {
const roundTripTime = Date.now() - this.pingTimestamp;
// Reset flag to avoid automatic timeout and close.
this.pingTimestamp = 0;
// emit event
const pongEvent = {
roundTripTime,
};
this.emitEvent([this.eventEmitter], types_1.EventType.PONG, pongEvent);
return;
}
const routeEvent = {
target: inMessage.target.toString(),
fromMsgId: inMessage.msgId,
data: inMessage.data,
expectingReply: inMessage.expectingReply
};
this.emitEvent([this.eventEmitter], types_1.EventType.ROUTE, routeEvent);
}
}
}
};
this.processOutqueue = () => {
if (this.isBusyOut <= 0) {
return;
}
this.isBusyOut--;
this.dispatchOutgoing();
this.processOutqueue(); // In case isBusyOut counter got increased
};
this.dispatchOutgoing = () => {
const buffers = this.outgoingQueue.chunks.slice();
this.outgoingQueue.chunks.length = 0;
for (let index = 0; index < buffers.length; index++) {
this.socket.send(buffers[index]);
}
};
/**
* Check every pending message to see which have timeouted.
*
*/
this.checkTimeouts = () => {
if (!this._isOpened || this._isClosed) {
return;
}
const timeouted = this.getTimeoutedPendingMessages();
for (let index = 0; index < timeouted.length; index++) {
const sentMessage = timeouted[index];
this.cancelPendingMessage(sentMessage.msgId);
}
for (let index = 0; index < timeouted.length; index++) {
const sentMessage = timeouted[index];
const timeoutEvent = {};
this.emitEvent([sentMessage.eventEmitter], types_1.EventType.TIMEOUT, timeoutEvent);
const anyEvent = {
type: types_1.EventType.TIMEOUT,
event: timeoutEvent
};
this.emitEvent([sentMessage.eventEmitter], types_1.EventType.ANY, anyEvent);
}
setTimeout(this.checkTimeouts, 500);
};
this.socket = socket;
this.pendingReply = {};
this._isOpened = false;
this._isClosed = false;
this.pingInterval = pingInterval ?? types_1.DEFAULT_PING_INTERVAL;
this.dispatchLimit = -1;
this.isBusyOut = 0;
this.isBusyIn = 0;
this.instanceId = Buffer.from(crypto_1.default.randomBytes(8)).toString("hex");
this.incomingQueue = {
chunks: [],
messages: []
};
this.outgoingQueue = {
chunks: [],
};
this.eventEmitter = new eventemitter3_1.default();
this.socket.onError((error) => this.emitError(error));
this.socket.onClose(this.socketClose);
}
getInstanceId() {
return this.instanceId;
}
/**
* Get the general event emitter object.
* This is used to listen for incoming messages
* and socket events such as close and error.
*/
getEventEmitter() {
return this.eventEmitter;
}
/**
* Open this Messaging for inbound data.
*
* Do not open it until you have hooked the event emitter
* to not loose any incoming data.
*
* It is technically allowed to send data before opening,
* but the Messaging should be opened very shortly after
* sending so that replies can come through and so that
* timeouts are processed properly.
*/
open() {
if (this._isOpened || this._isClosed) {
return;
}
this._isOpened = true;
this.socket.onData(this.socketData);
this.checkTimeouts();
if (this.pingInterval > 0) {
this.enablePing(this.pingInterval);
}
}
isOpen() {
return this._isOpened && !this._isClosed;
}
isOpened() {
return this._isOpened;
}
isClosed() {
return this._isClosed;
}
/**
* Close this Messaging object and it's socket.
*
*/
close() {
if (this._isClosed) {
return;
}
this.disablePing();
// Note the socket was already open when it was passed into Messaging.
this.socket?.close();
}
cork() {
this.dispatchLimit = 0;
}
uncork(limit) {
this.dispatchLimit = limit ?? -1;
}
/**
* Send message to remote.
*
* The returned EventEmitter can be hooked as eventEmitter.on("reply", fn) or
* const data: ReplyEvent = await once(eventEmitter, "reply");
* Other events are "close" (CloseEvent) and "any" which trigger both for "reply", "close" and "error" (ErrorEvent). There is also "timeout" (TimeoutEvent).
*
* A timeouted message is removed from memory and a TIMEOUT is emitted.
*
* @param target: Buffer | string either set as routing target as string, or as message ID in reply to (as buffer).
* The receiving Messaging instance will check if target matches a msg ID which is waiting for a reply and in such case the message till be emitted on that EventEmitter,
* or else it will pass it to the router to see if it matches some route.
* @param data: Buffer of data to be sent. Note that data (payload) cannot exceed MESSAGE_MAX_BYTES.
* @param timeout milliseconds to wait for the first reply (defaults to -1)
* -1 means we are not expecting a reply
* 0 or greater means that we are expecting a reply, 0 means wait forever
* @param stream set to true if expecting multiple replies (defaults to false)
* This requires that timeout is set to 0 or greater
* @param timeoutStream milliseconds to wait for secondary replies, 0 means forever (default).
* Only relevant if expecting multiple replies (stream = true).
* @return SendReturn | undefined
* SendReturn.msgId is always set
* SendReturn.eventEmitter property is set if expecting reply
* undefined is returned as a silent error when the Messaging is closed and data cannot be sent again on this Messaging instance.
* @throws on malformed input
*/
send(target, data, timeout = -1, stream = false, timeoutStream = 0) {
if (this._isClosed) {
console.debug("Messaging is closed, cannot send");
return undefined;
}
if (typeof target === "string") {
target = Buffer.from(target);
}
data = data ?? Buffer.alloc(0);
if (data.length > types_1.MESSAGE_MAX_BYTES) {
throw new Error(`Data chunk to send cannot exceed ${types_1.MESSAGE_MAX_BYTES} bytes. Trying to send ${data.length} bytes`);
}
if (target.length > 255) {
throw new Error("target length cannot exceed 255 bytes");
}
const msgId = Messaging.GenerateMsgId();
const expectingReply = timeout > -1 ?
(stream ? types_1.ExpectingReply.MULTIPLE : types_1.ExpectingReply.SINGLE) : types_1.ExpectingReply.NONE;
const header = {
version: 0,
target,
dataLength: data.length,
msgId,
config: expectingReply
};
const headerBuffer = Messaging.EncodeHeader(header);
this.outgoingQueue.chunks.push(headerBuffer);
this.outgoingQueue.chunks.push(data);
this.isBusyOut++;
setImmediate(this.processOutqueue);
if (expectingReply === types_1.ExpectingReply.NONE) {
return { msgId };
}
const eventEmitter = new eventemitter3_1.default();
this.pendingReply[msgId.toString("hex")] = {
timestamp: this.getNow(),
msgId,
timeout: timeout,
stream: Boolean(stream),
eventEmitter,
timeoutStream: timeoutStream,
replyCounter: 0,
isCleared: false,
};
return { eventEmitter, msgId };
}
/**
* Enable to send frequent pings on the socket.
* This will help to detect silent disconnects.
* @param pingInterval how many milliseconds to wait between each ping.
* Default is 10000 (10 sec). 0 means disabled.
*/
enablePing(pingInterval = 10000) {
if (this._isClosed) {
return;
}
this.disablePing();
this.pingInterval = pingInterval;
this.pingTimestamp = 0; // reset
if (this.pingInterval > 0) {
this.pingTimeout = setTimeout(this.sendPing, this.pingInterval);
}
}
disablePing() {
if (this.pingTimeout !== undefined) {
clearTimeout(this.pingTimeout);
this.pingTimeout = undefined;
this.pingTimestamp = 0;
}
}
/**
* @returns the underlaying socket client.
*/
getClient() {
return this.socket;
}
getNow() {
return Date.now();
}
static GenerateMsgId() {
const msgId = Buffer.from(crypto_1.default.randomBytes(types_1.MSG_ID_LENGTH));
return msgId;
}
/**
* @throws on malformed input
*/
static EncodeHeader(header) {
if (header.target.length > 255) {
throw new Error("Target length cannot exceed 255 bytes");
}
if (header.msgId.length !== types_1.MSG_ID_LENGTH) {
throw new Error(`msgId length must be exactly ${types_1.MSG_ID_LENGTH} bytes long`);
}
const headerLength = 1 + 4 + 1 + types_1.MSG_ID_LENGTH + 1 + header.target.length;
const totalLength = headerLength + header.dataLength;
const buffer = Buffer.alloc(headerLength);
let pos = 0;
buffer.writeUInt8(pos, header.version);
pos++;
buffer.writeUInt32LE(totalLength, pos);
pos = pos + 4;
buffer.writeUInt8(header.config, pos);
pos++;
header.msgId.copy(buffer, pos);
pos = pos + header.msgId.length;
buffer.writeUInt8(header.target.length, pos);
pos++;
header.target.copy(buffer, pos);
return buffer;
}
/**
* @throws on malformed input
*/
static DecodeHeader(buffer) {
let pos = 0;
const version = buffer.readUInt8(pos);
if (version !== 0) {
throw new Error("Unexpected version nr, only supporting version 0");
}
pos++;
const totalLength = buffer.readUInt32LE(pos);
if (totalLength !== buffer.length) {
throw new Error("Mismatch in expected length and provided buffer length");
}
pos = pos + 4;
const config = buffer.readUInt8(pos);
pos++;
const msgId = buffer.slice(pos, pos + types_1.MSG_ID_LENGTH);
pos = pos + types_1.MSG_ID_LENGTH;
const targetLength = buffer.readUInt8(pos);
pos++;
const target = buffer.slice(pos, pos + targetLength);
pos = pos + targetLength;
const data = buffer.slice(pos);
const dataLength = data.length;
const header = {
version,
target,
msgId,
config,
dataLength
};
return [header, data];
}
/**
* Extract length as single buffer and modify the buffers array in place.
*
*/
extractBuffer(buffers, length) {
let count = 0;
for (let index = 0; index < buffers.length; index++) {
count = count + buffers[index].length;
}
if (count < length) {
// Not enough data ready.
return undefined;
}
let extracted = Buffer.alloc(0);
while (extracted.length < length) {
const bytesNeeded = length - extracted.length;
const buffer = buffers[0];
if (buffer.length <= bytesNeeded) {
// Take the whole buffer and remove it from list
buffers.shift();
extracted = Buffer.concat([extracted, buffer]);
}
else {
// Take part of the buffer and modify it in place
extracted = Buffer.concat([extracted, buffer.slice(0, bytesNeeded)]);
buffers[0] = buffer.slice(bytesNeeded);
}
}
return extracted;
}
emitEvent(eventEmitters, eventType, arg) {
for (let index = 0; index < eventEmitters.length; index++) {
eventEmitters[index].emit(eventType, arg);
}
}
getAllEventEmitters() {
const eventEmitters = [];
Object.keys(this.pendingReply).forEach(msgId => {
eventEmitters.push(this.pendingReply[msgId].eventEmitter);
});
eventEmitters.push(this.eventEmitter);
return eventEmitters;
}
getTimeoutedPendingMessages() {
const timeouted = [];
const now = this.getNow();
Object.keys(this.pendingReply).forEach(msgId => {
const sentMessage = this.pendingReply[msgId];
if (sentMessage.isCleared) {
return;
}
if (sentMessage.replyCounter === 0) {
if (sentMessage.timeout > 0 && now > sentMessage.timestamp + sentMessage.timeout) {
timeouted.push(sentMessage);
}
}
else {
if (sentMessage.timeoutStream && now > sentMessage.timestamp + sentMessage.timeoutStream) {
timeouted.push(sentMessage);
}
}
});
return timeouted;
}
}
exports.Messaging = Messaging;
/**
* Mimicking the async/await once function from the nodejs events module.
* Because EventEmitter3 module doesn't seem to support the async/await promise feature of nodejs events once() function.
*/
function once(eventEmitter, eventName) {
return new Promise((resolve, reject) => {
try {
eventEmitter.once(eventName, resolve);
}
catch (e) {
reject(e);
}
});
}
exports.once = once;