UNPKG

pocket-messaging

Version:

A small cryptographic messaging library written in TypeScript both for browser and nodejs supporting TCP and WebSockets

859 lines (724 loc) 27.1 kB
import {ClientInterface} from "pocket-sockets"; import EventEmitter from "eventemitter3"; import crypto from "crypto"; // Only used for synchronous randomBytes. import { SentMessage, Header, OutgoingQueue, InMessage, IncomingQueue, RouteEvent, ReplyEvent, TimeoutEvent, ErrorEvent, PongEvent, AnyEvent, CloseEvent, EventType, MESSAGE_MAX_BYTES, SendReturn, ExpectingReply, DEFAULT_PING_INTERVAL, MSG_ID_LENGTH, PING_ROUTE, PONG_ROUTE, } from "./types"; import { PocketConsole, } from "pocket-console"; const console = PocketConsole({module: "Messaging"}); export class Messaging { /** * Messages sent from here which are expecting replies. * */ protected pendingReply: {[msgId: string]: SentMessage}; /** * Data read on socket and transformed to messages. */ protected incomingQueue: IncomingQueue; /** * Messages transformed and sent. */ protected outgoingQueue: OutgoingQueue; /** * The general event emitter for incoming messages and socket events. * Reply messages are not emitted using this object but are emitted on message specific event emitters. */ protected eventEmitter: EventEmitter; /** * The given client socket to communicate with. */ protected socket: ClientInterface; /** * Set to true if we have opened. */ protected _isOpened: boolean; /** * Set to true if we have closed. */ protected _isClosed: boolean; /** ID of the timeout in use for ping. */ protected pingTimeout: ReturnType<typeof setTimeout> | undefined; /** Milliseconds to wait between each ping. */ protected pingInterval: number; /** Keep track of pending ping, pong resets it. */ protected pingTimestamp: number = 0; /** * How many messages we allow through. * 0 means cork it up * -1 means unlimited. */ protected dispatchLimit: number; protected isBusyOut: number; protected isBusyIn: number; protected instanceId: string; /** * @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: ClientInterface, pingInterval?: number) { this.socket = socket; this.pendingReply = {}; this._isOpened = false; this._isClosed = false; this.pingInterval = pingInterval ?? DEFAULT_PING_INTERVAL; this.dispatchLimit = -1; this.isBusyOut = 0; this.isBusyIn = 0; this.instanceId = Buffer.from(crypto.randomBytes(8)).toString("hex"); this.incomingQueue = { chunks: [], messages: [] }; this.outgoingQueue = { chunks: [], }; this.eventEmitter = new EventEmitter(); this.socket.onError( (error) => this.emitError(error) ); this.socket.onClose(this.socketClose); } public getInstanceId(): string { return this.instanceId; } /** * Remove a stored pending message so that it cannot receive any more replies. */ public cancelPendingMessage = (msgId: Buffer) => { 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. */ public isMessagePending = (msgId: Buffer): boolean => { 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. */ public clearTimeout = (msgId: Buffer) => { const sentMessage = this.pendingReply[msgId.toString("hex")]; if (sentMessage) { sentMessage.isCleared = true; } }; /** * Get the general event emitter object. * This is used to listen for incoming messages * and socket events such as close and error. */ public getEventEmitter(): EventEmitter { 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. */ public 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); } } public isOpen(): boolean { return this._isOpened && !this._isClosed; } public isOpened(): boolean { return this._isOpened; } public isClosed(): boolean { return this._isClosed; } /** * Close this Messaging object and it's socket. * */ public close() { if (this._isClosed) { return; } this.disablePing(); // Note the socket was already open when it was passed into Messaging. this.socket?.close(); } public cork() { this.dispatchLimit = 0; } public uncork(limit?: number) { 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 */ public send(target: Buffer | string, data?: Buffer, timeout: number = -1, stream: boolean = false, timeoutStream: number = 0): SendReturn | undefined { 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 > MESSAGE_MAX_BYTES) { throw new Error(`Data chunk to send cannot exceed ${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 ? ExpectingReply.MULTIPLE : ExpectingReply.SINGLE) : ExpectingReply.NONE; const header: 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 === ExpectingReply.NONE) { return {msgId}; } const eventEmitter = new EventEmitter(); 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. */ public enablePing(pingInterval: number = 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 ); } } public disablePing() { if (this.pingTimeout !== undefined) { clearTimeout(this.pingTimeout); this.pingTimeout = undefined; this.pingTimestamp = 0; } } /** * @returns the underlaying socket client. */ public getClient(): ClientInterface { return this.socket; } protected getNow(): number { return Date.now(); } public static GenerateMsgId(): Buffer { const msgId = Buffer.from(crypto.randomBytes(MSG_ID_LENGTH)); return msgId; } /** * @throws on malformed input */ public static EncodeHeader(header: Header): Buffer { if (header.target.length > 255) { throw new Error("Target length cannot exceed 255 bytes"); } if (header.msgId.length !== MSG_ID_LENGTH) { throw new Error(`msgId length must be exactly ${MSG_ID_LENGTH} bytes long`); } const headerLength = 1 + 4 + 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 */ public static DecodeHeader(buffer: Buffer): [Header, 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 + MSG_ID_LENGTH); pos = pos + 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: Header = { version, target, msgId, config, dataLength }; return [header, data]; } /** * Extract length as single buffer and modify the buffers array in place. * */ protected extractBuffer(buffers: Buffer[], length: number): Buffer | undefined { 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; } protected emitEvent(eventEmitters: EventEmitter[], eventType: EventType, arg?: any) { for (let index = 0; index < eventEmitters.length; index++) { eventEmitters[index].emit(eventType, arg); } } protected getAllEventEmitters(): EventEmitter[] { const eventEmitters: EventEmitter[] = []; Object.keys(this.pendingReply).forEach( msgId => { eventEmitters.push(this.pendingReply[msgId].eventEmitter); }); eventEmitters.push(this.eventEmitter); return eventEmitters; } /** * Notify all pending messages and the main emitter about an error. * * @param message the error message * */ protected emitError = (message: string) => { const eventEmitters = this.getAllEventEmitters(); const errorEvent: ErrorEvent = { error: message }; this.emitEvent(eventEmitters, EventType.ERROR, errorEvent); const anyEvent: AnyEvent = { type: EventType.ERROR, event: errorEvent }; this.emitEvent(eventEmitters, EventType.ANY, anyEvent); } /** * Notify all pending messages about the close. */ protected socketClose = (hadError: boolean) => { if (this._isClosed) { return; } this._isClosed = true; this.disablePing(); const eventEmitters = this.getAllEventEmitters(); this.pendingReply = {}; // Remove all from memory const closeEvent: CloseEvent = { hadError: Boolean(hadError) }; this.emitEvent(eventEmitters, EventType.CLOSE, closeEvent); const anyEvent: AnyEvent = { type: EventType.CLOSE, event: closeEvent }; this.emitEvent(eventEmitters, 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. */ protected 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(PING_ROUTE); } if (this.pingInterval > 0) { this.pingTimeout = setTimeout( this.sendPing, this.pingInterval ); } } /** * Buffer incoming raw data from the socket and process it. */ protected socketData = (data: Buffer | string) => { if (Buffer.isBuffer(data)) { this.incomingQueue.chunks.push(data); this.isBusyIn++; this.processInqueue(); } else { throw new Error("Messaging does not work with text data"); } } protected 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. * */ protected assembleIncoming = (): boolean => { 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: [Header, Buffer]; 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]: [Header, Buffer] = ret; const inMessage: InMessage = { target: header.target, msgId: header.msgId, data, expectingReply: header.config & (ExpectingReply.SINGLE + ExpectingReply.MULTIPLE), // other config bits are reserved for future use }; this.incomingQueue.messages.push(inMessage); } return true; }; /** * Dispatch messages on event emitters. * */ protected 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: ReplyEvent = { toMsgId: inMessage.target, fromMsgId: inMessage.msgId, data: inMessage.data, expectingReply: inMessage.expectingReply }; this.emitEvent([pendingReply.eventEmitter], EventType.REPLY, replyEvent); const anyEvent: AnyEvent = { type: EventType.REPLY, event: replyEvent }; this.emitEvent([pendingReply.eventEmitter], 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() === PING_ROUTE.toLowerCase()) { // Send empty pong message. // Not expecting a reply on this. this.send(PONG_ROUTE); return; } else if (inMessage.target.toString().toLowerCase() === PONG_ROUTE.toLowerCase()) { const roundTripTime = Date.now() - this.pingTimestamp; // Reset flag to avoid automatic timeout and close. this.pingTimestamp = 0; // emit event const pongEvent: PongEvent = { roundTripTime, }; this.emitEvent([this.eventEmitter], EventType.PONG, pongEvent); return; } const routeEvent: RouteEvent = { target: inMessage.target.toString(), fromMsgId: inMessage.msgId, data: inMessage.data, expectingReply: inMessage.expectingReply }; this.emitEvent([this.eventEmitter], EventType.ROUTE, routeEvent); } } } } protected processOutqueue = () => { if (this.isBusyOut <= 0) { return; } this.isBusyOut--; this.dispatchOutgoing(); this.processOutqueue(); // In case isBusyOut counter got increased } protected 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. * */ protected checkTimeouts = () => { if (!this._isOpened || this._isClosed) { return; } const timeouted: SentMessage[] = 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: TimeoutEvent = { }; this.emitEvent([sentMessage.eventEmitter], EventType.TIMEOUT, timeoutEvent); const anyEvent: AnyEvent = { type: EventType.TIMEOUT, event: timeoutEvent }; this.emitEvent([sentMessage.eventEmitter], EventType.ANY, anyEvent); } setTimeout(this.checkTimeouts, 500); } protected getTimeoutedPendingMessages(): SentMessage[] { const timeouted: SentMessage[] = []; 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; } } /** * 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. */ export function once(eventEmitter: EventEmitter, eventName: string | symbol): Promise<any> { return new Promise( (resolve, reject) => { try { eventEmitter.once(eventName, resolve); } catch(e) { reject(e); } }); }