UNPKG

@eventmsg/core

Version:

EventMsgV3 TypeScript library - Core protocol implementation with transport abstraction

1,570 lines (1,556 loc) 48.5 kB
import { EventEmitter } from "eventemitter3"; import { createConsola } from "consola/basic"; //#region src/errors/event-msg-error.ts /** * Base error class for all EventMsg-related errors. * Provides consistent error handling with context and solutions. */ var EventMsgError = class extends Error { /** Error code for programmatic handling */ code; /** Additional context about the error */ context; /** Suggested solutions for the error */ solutions; /** Original error that caused this error */ cause; constructor(message, code, options = {}) { super(message); this.name = this.constructor.name; this.code = code; this.context = options.context; this.solutions = options.solutions || []; this.cause = options.cause; if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor); } /** * Get a detailed error message including context and solutions */ getDetailedMessage() { let details = `${this.name}: ${this.message}`; if (this.code) details += ` (${this.code})`; if (this.context && Object.keys(this.context).length > 0) { details += "\nContext:"; for (const [key, value] of Object.entries(this.context)) details += `\n ${key}: ${JSON.stringify(value)}`; } if (this.solutions.length > 0) { details += "\nSuggested solutions:"; for (const solution of this.solutions) details += `\n • ${solution}`; } if (this.cause) details += `\nCaused by: ${this.cause.message}`; return details; } /** * Convert error to JSON for logging/serialization */ toJSON() { return { name: this.name, message: this.message, code: this.code, context: this.context, solutions: this.solutions, stack: this.stack, cause: this.cause ? { name: this.cause.name, message: this.cause.message } : void 0 }; } }; //#endregion //#region src/errors/protocol-error.ts /** * Error related to EventMsgV3 protocol operations (encoding, decoding, validation) */ var ProtocolError = class extends EventMsgError { constructor(message, options = {}) { super(message, "PROTOCOL_ERROR", { ...options, solutions: options.solutions || [ "Check message format", "Verify protocol specification compliance", "Review message content" ] }); } }; /** * Message encoding failed */ var EncodingError = class extends ProtocolError { constructor(message = "Failed to encode message", options = {}) { super(message, { ...options, solutions: [ "Check event name length (≤64 bytes)", "Check event data length (≤3048 bytes)", "Verify data is JSON serializable", "Check for invalid characters" ] }); this.code = "ENCODING_ERROR"; } }; /** * Message decoding failed */ var DecodingError = class extends ProtocolError { constructor(message = "Failed to decode message", options = {}) { super(message, { ...options, solutions: [ "Check message framing (SOH, STX, US, EOT)", "Verify byte stuffing is correct", "Check for data corruption", "Ensure sender uses same protocol version" ] }); this.code = "DECODING_ERROR"; } }; /** * Message validation failed */ var ValidationError = class extends ProtocolError { constructor(message = "Message validation failed", options = {}) { super(message, { ...options, solutions: [ "Check address ranges (0-255)", "Verify message size limits", "Check required fields are present", "Validate data types" ] }); this.code = "VALIDATION_ERROR"; } }; /** * Invalid message format */ var InvalidMessageError = class extends ProtocolError { constructor(message = "Invalid message format", options = {}) { super(message, { ...options, solutions: [ "Check message starts with SOH (0x01)", "Check message ends with EOT (0x04)", "Verify minimum message length (10 bytes)", "Check header is exactly 7 bytes after unstuffing" ] }); this.code = "INVALID_MESSAGE_ERROR"; } }; //#endregion //#region src/errors/timeout-error.ts /** * Error for operations that exceed their timeout duration */ var TimeoutError = class extends EventMsgError { /** Timeout duration in milliseconds */ timeoutMs; /** Operation that timed out */ operation; constructor(message, timeoutMs, operation = "operation", options = {}) { super(message, "TIMEOUT_ERROR", { ...options, context: { ...options.context, timeoutMs, operation }, solutions: [ "Increase timeout duration", "Check network connectivity", "Verify target device is responding", "Reduce message frequency" ] }); this.timeoutMs = timeoutMs; this.operation = operation; } }; /** * Send operation timed out */ var SendTimeoutError = class extends TimeoutError { constructor(timeoutMs, options = {}) { super(`Send operation timed out after ${timeoutMs}ms`, timeoutMs, "send", { ...options, solutions: [ "Increase send timeout", "Check transport connection", "Verify target device is reachable", "Check for network congestion" ] }); this.code = "SEND_TIMEOUT_ERROR"; } }; /** * WaitFor operation timed out */ var WaitForTimeoutError = class extends TimeoutError { /** Event name that was being waited for */ eventName; constructor(eventName, timeoutMs, options = {}) { super(`waitFor('${eventName}') timed out after ${timeoutMs}ms`, timeoutMs, "waitFor", { ...options, context: { ...options.context, eventName }, solutions: [ "Increase waitFor timeout", "Check if event name is correct", "Verify sender is responding", "Check event filter conditions" ] }); this.eventName = eventName; this.code = "WAIT_FOR_TIMEOUT_ERROR"; } }; /** * Connection timeout error */ var ConnectionTimeoutError = class extends TimeoutError { constructor(timeoutMs, options = {}) { super(`Connection attempt timed out after ${timeoutMs}ms`, timeoutMs, "connect", { ...options, solutions: [ "Increase connection timeout", "Check device availability", "Verify connection parameters", "Check for interference" ] }); this.code = "CONNECTION_TIMEOUT_ERROR"; } }; //#endregion //#region src/errors/transport-error.ts /** * Error related to transport layer operations (connection, sending, receiving) */ var TransportError = class extends EventMsgError { constructor(message, options = {}) { super(message, "TRANSPORT_ERROR", { ...options, solutions: options.solutions || [ "Check transport connection", "Verify transport configuration", "Retry the operation" ] }); } }; /** * Transport connection failed */ var ConnectionError = class extends TransportError { constructor(message = "Failed to connect to transport", options = {}) { super(message, { ...options, solutions: [ "Check if the device is available", "Verify connection parameters", "Ensure no other process is using the transport", "Check device permissions" ] }); this.code = "CONNECTION_ERROR"; } }; /** * Transport send operation failed */ var SendError = class extends TransportError { constructor(message = "Failed to send data through transport", options = {}) { super(message, { ...options, solutions: [ "Check transport connection status", "Verify message size is within limits", "Retry sending the message", "Check for transport buffer overflow" ] }); this.code = "SEND_ERROR"; } }; /** * Transport disconnection error */ var DisconnectionError = class extends TransportError { constructor(message = "Transport disconnected unexpectedly", options = {}) { super(message, { ...options, solutions: [ "Check physical connection", "Implement reconnection logic", "Monitor connection status", "Handle graceful disconnection" ] }); this.code = "DISCONNECTION_ERROR"; } }; //#endregion //#region src/errors/validation-error.ts /** * Error for input validation failures */ var ValidationError$1 = class extends EventMsgError { /** Field that failed validation */ field; /** Value that was invalid */ value; constructor(message, options = {}) { super(message, "VALIDATION_ERROR", { ...options, context: { ...options.context, field: options.field, value: options.value }, solutions: [ "Check input parameters", "Verify value ranges and types", "Review API documentation" ] }); this.field = options.field; this.value = options.value; } }; /** * Address validation error (0-255 range) */ var AddressValidationError = class extends ValidationError$1 { constructor(address, field = "address", options = {}) { super(`Invalid ${field}: ${address}. Must be 0-255`, { ...options, field, value: address, solutions: [ "Use address in range 0-255", "Check for negative values", "Verify address is an integer" ] }); this.code = "ADDRESS_VALIDATION_ERROR"; } }; /** * Message size validation error */ var MessageSizeError = class extends ValidationError$1 { constructor(size, maxSize, field = "message", options = {}) { super(`${field} size ${size} bytes exceeds maximum ${maxSize} bytes`, { ...options, field, value: size, context: { ...options.context, maxSize }, solutions: [ `Reduce ${field} size to ${maxSize} bytes or less`, "Consider splitting large messages", "Use more efficient encoding" ] }); this.code = "MESSAGE_SIZE_ERROR"; } }; //#endregion //#region src/types/logger.ts /** * Default logging configuration */ const DEFAULT_LOGGING_CONFIG = { enabled: true, level: 3 }; /** * Log namespaces for tagging */ const LOG_NAMESPACES = { CORE: "eventmsg:core", PROTOCOL: "eventmsg:protocol", TRANSPORT: "eventmsg:transport", TRANSPORT_WEBBLE: "eventmsg:transport:webble" }; //#endregion //#region src/internal/logger.ts let globalConsola = createConsola({ level: DEFAULT_LOGGING_CONFIG.level ?? 3 }); /** * Configure global logging */ function configureLogging(config) { const finalConfig = { ...DEFAULT_LOGGING_CONFIG, ...config }; if (finalConfig.enabled && finalConfig.level !== -999) globalConsola = createConsola({ level: finalConfig.level ?? 3 }); else globalConsola = createConsola({ level: 0, reporters: [] }); } /** * Get logger for a component */ function getLogger(namespace) { return globalConsola.withTag(LOG_NAMESPACES[namespace]); } /** * Simple hex dump for debugging */ function hexDump(data, maxBytes = 64) { const limited = data.length > maxBytes ? data.slice(0, maxBytes) : data; const hex = Array.from(limited).map((b) => b.toString(16).padStart(2, "0")).join(" "); if (data.length > maxBytes) return `${hex} ... (${data.length - maxBytes} more bytes)`; return hex; } //#endregion //#region src/internal/byte-stuffing.ts /** * EventMsgV3 Byte Stuffing Implementation * * Implements the exact byte stuffing algorithm from the C implementation * to ensure protocol compatibility. */ /** * Control characters used in EventMsgV3 protocol */ const CONTROL_CHARS = { SOH: 1, STX: 2, EOT: 4, US: 31, ESC: 27 }; /** * XOR mask for byte stuffing */ const STUFF_MASK = 32; /** * Check if a byte is a control character that needs stuffing */ function isControlChar(byte) { return byte === CONTROL_CHARS.SOH || byte === CONTROL_CHARS.STX || byte === CONTROL_CHARS.EOT || byte === CONTROL_CHARS.US || byte === CONTROL_CHARS.ESC; } /** * Stuff (encode) bytes by escaping control characters * * Algorithm: * - If byte is a control character: Insert ESC, then XOR byte with 0x20 * - Otherwise: Insert byte as-is * * @param data Input data to stuff * @returns Stuffed data */ function stuff(data) { const result = []; for (let i = 0; i < data.length; i++) { const byte = data[i]; if (isControlChar(byte)) { result.push(CONTROL_CHARS.ESC); result.push(byte ^ STUFF_MASK); } else result.push(byte); } return new Uint8Array(result); } /** * Unstuff (decode) bytes by handling escaped characters * * Algorithm: * - If byte is ESC: Mark next byte as escaped, continue * - If previous byte was ESC: XOR current byte with 0x20, add to output * - Otherwise: Add byte as-is to output * * @param data Stuffed data to unstuff * @returns Unstuffed data * @throws {Error} If data contains invalid escape sequences */ function unstuff(data) { const result = []; let escaped = false; for (let i = 0; i < data.length; i++) { const byte = data[i]; if (escaped) { result.push(byte ^ STUFF_MASK); escaped = false; } else if (byte === CONTROL_CHARS.ESC) escaped = true; else result.push(byte); } if (escaped) throw new Error("Invalid byte stuffing: data ends with incomplete escape sequence"); return new Uint8Array(result); } /** * Calculate the maximum possible size after stuffing * In worst case, every byte could be a control character, doubling the size * * @param originalSize Original data size * @returns Maximum size after stuffing */ function getMaxStuffedSize(originalSize) { return originalSize * 2; } /** * Calculate the minimum possible size after unstuffing * In best case, no bytes are stuffed * * @param stuffedSize Stuffed data size * @returns Minimum size after unstuffing */ function getMinUnstuffedSize(stuffedSize) { return Math.floor(stuffedSize / 2); } /** * Test if data contains any control characters that would need stuffing * * @param data Data to test * @returns True if data contains control characters */ function needsStuffing(data) { for (let i = 0; i < data.length; i++) if (isControlChar(data[i])) return true; return false; } /** * Validate that stuffed data has proper escape sequences * * @param data Stuffed data to validate * @returns True if data has valid stuffing */ function isValidStuffing(data) { let escaped = false; for (let i = 0; i < data.length; i++) { const byte = data[i]; if (escaped) { const unescaped = byte ^ STUFF_MASK; if (!isControlChar(unescaped)) return false; escaped = false; } else if (byte === CONTROL_CHARS.ESC) escaped = true; else if (isControlChar(byte)) return false; } return !escaped; } //#endregion //#region src/types/config.ts /** * Default protocol options */ const DEFAULT_PROTOCOL_OPTIONS = { strictValidation: true, maxEventNameLength: 64, maxEventDataLength: 3048 }; /** * Default configuration values */ const DEFAULT_CONFIG = { maxMessageSize: 4096, messageTimeout: 5e3, encoding: "utf8", logging: DEFAULT_LOGGING_CONFIG, protocol: DEFAULT_PROTOCOL_OPTIONS }; //#endregion //#region src/protocol.ts /** * EventMsgV3 Protocol Implementation * * Handles encoding and decoding of EventMsgV3 binary protocol messages. * Message format: [SOH][StuffedHeader][STX][StuffedEventName][US][StuffedEventData][EOT] */ var Protocol = class { options; encoding; logger; constructor(config) { this.options = { ...DEFAULT_PROTOCOL_OPTIONS, ...config.protocol }; this.encoding = config.encoding ?? "utf8"; this.logger = getLogger("PROTOCOL"); } /** * Encode a message into EventMsgV3 binary format * * @param header Message header (7 bytes) * @param eventName Event name string * @param eventData Event data string (JSON) * @returns Encoded binary message * @throws {EncodingError} If encoding fails * @throws {MessageSizeError} If message exceeds size limits */ encode(header, eventName, eventData) { this.logger.debug("Starting message encode", { eventName, eventDataLength: eventData.length, header, encoding: this.encoding }); try { this.validateHeader(header); this.validateEventName(eventName); this.validateEventData(eventData); const eventNameBytes = this.stringToBytes(eventName); const eventDataBytes = this.stringToBytes(eventData); const headerBytes = this.serializeHeader(header); const stuffedHeader = stuff(headerBytes); const stuffedEventName = stuff(eventNameBytes); const stuffedEventData = stuff(eventDataBytes); this.logger.trace("Byte stuffing complete", { headerSize: `${headerBytes.length} -> ${stuffedHeader.length}`, nameSize: `${eventNameBytes.length} -> ${stuffedEventName.length}`, dataSize: `${eventDataBytes.length} -> ${stuffedEventData.length}` }); const totalSize = 1 + stuffedHeader.length + 1 + stuffedEventName.length + 1 + stuffedEventData.length + 1; if (totalSize > this.options.maxEventDataLength + 1e3) throw new MessageSizeError(totalSize, this.options.maxEventDataLength + 1e3, "encoded message"); const message = new Uint8Array(totalSize); let offset = 0; message[offset++] = CONTROL_CHARS.SOH; message.set(stuffedHeader, offset); offset += stuffedHeader.length; message[offset++] = CONTROL_CHARS.STX; message.set(stuffedEventName, offset); offset += stuffedEventName.length; message[offset++] = CONTROL_CHARS.US; message.set(stuffedEventData, offset); offset += stuffedEventData.length; message[offset++] = CONTROL_CHARS.EOT; const finalMessage = message.subarray(0, offset); this.logger.info("Message encoded successfully", { eventName, finalSize: finalMessage.length, messageId: header.messageId }); this.logger.trace("Encoded message hex dump", { hex: hexDump(finalMessage) }); return finalMessage; } catch (error) { this.logger.error("Message encoding failed", { error: error instanceof Error ? error.message : String(error), eventName, eventDataLength: eventData.length, cause: error instanceof Error ? error.name : "Unknown" }); if (error instanceof MessageSizeError) throw error; throw new EncodingError("Failed to encode EventMsgV3 message", { context: { eventName, eventDataLength: eventData.length }, cause: error instanceof Error ? error : new Error(String(error)) }); } } /** * Encode a message with binary data into EventMsgV3 format * * @param header Message header (7 bytes) * @param eventName Event name string * @param eventData Binary event data * @returns Encoded binary message * @throws {EncodingError} If encoding fails * @throws {MessageSizeError} If message exceeds size limits */ encodeBinary(header, eventName, eventData) { this.logger.debug("Starting binary message encode", { eventName, eventDataLength: eventData.length, header }); try { this.validateHeader(header); this.validateEventName(eventName); if (eventData.length > this.options.maxEventDataLength) throw new MessageSizeError(eventData.length, this.options.maxEventDataLength, "eventData"); const eventNameBytes = this.stringToBytes(eventName); const headerBytes = this.serializeHeader(header); const stuffedHeader = stuff(headerBytes); const stuffedEventName = stuff(eventNameBytes); const stuffedEventData = stuff(eventData); this.logger.trace("Binary byte stuffing complete", { headerSize: `${headerBytes.length} -> ${stuffedHeader.length}`, nameSize: `${eventNameBytes.length} -> ${stuffedEventName.length}`, dataSize: `${eventData.length} -> ${stuffedEventData.length}` }); const totalSize = 1 + stuffedHeader.length + 1 + stuffedEventName.length + 1 + stuffedEventData.length + 1; if (totalSize > this.options.maxEventDataLength + 1e3) throw new MessageSizeError(totalSize, this.options.maxEventDataLength + 1e3, "encoded message"); const message = new Uint8Array(totalSize); let offset = 0; message[offset++] = CONTROL_CHARS.SOH; message.set(stuffedHeader, offset); offset += stuffedHeader.length; message[offset++] = CONTROL_CHARS.STX; message.set(stuffedEventName, offset); offset += stuffedEventName.length; message[offset++] = CONTROL_CHARS.US; message.set(stuffedEventData, offset); offset += stuffedEventData.length; message[offset++] = CONTROL_CHARS.EOT; const finalMessage = message.subarray(0, offset); this.logger.info("Binary message encoded successfully", { eventName, finalSize: finalMessage.length, messageId: header.messageId }); this.logger.trace("Encoded binary message hex dump", { hex: hexDump(finalMessage) }); return finalMessage; } catch (error) { this.logger.error("Binary message encoding failed", { error: error instanceof Error ? error.message : String(error), eventName, eventDataLength: eventData.length, cause: error instanceof Error ? error.name : "Unknown" }); if (error instanceof MessageSizeError) throw error; throw new EncodingError("Failed to encode EventMsgV3 binary message", { context: { eventName, eventDataLength: eventData.length }, cause: error instanceof Error ? error : new Error(String(error)) }); } } /** * Decode a binary message into EventMsgV3 components * * @param data Binary message data * @returns Decoded message components * @throws {DecodingError} If decoding fails * @throws {InvalidMessageError} If message format is invalid */ decode(data) { this.logger.debug("Starting message decode", { messageLength: data.length, encoding: this.encoding }); this.logger.trace("Incoming message hex dump", { hex: hexDump(data) }); try { if (data.length < 10) throw new InvalidMessageError(`Message too short: ${data.length} bytes (minimum 10)`, { context: { messageLength: data.length } }); if (data[0] !== CONTROL_CHARS.SOH) throw new InvalidMessageError(`Message must start with SOH (0x01), got 0x${data[0]?.toString(16).padStart(2, "0")}`, { context: { firstByte: data[0] } }); if (data.at(-1) !== CONTROL_CHARS.EOT) throw new InvalidMessageError(`Message must end with EOT (0x04), got 0x${data.at(-1)?.toString(16).padStart(2, "0")}`, { context: { lastByte: data.at(-1) } }); const stxIndex = this.findControlChar(data, CONTROL_CHARS.STX, 1); const usIndex = this.findControlChar(data, CONTROL_CHARS.US, stxIndex + 1); if (stxIndex === -1) throw new InvalidMessageError("STX (0x02) not found in message"); if (usIndex === -1) throw new InvalidMessageError("US (0x1F) not found in message"); const stuffedHeader = data.subarray(1, stxIndex); const stuffedEventName = data.subarray(stxIndex + 1, usIndex); const stuffedEventData = data.subarray(usIndex + 1, data.length - 1); const headerBytes = unstuff(stuffedHeader); const eventNameBytes = unstuff(stuffedEventName); const eventDataBytes = unstuff(stuffedEventData); this.logger.trace("Byte unstuffing complete", { headerSize: `${stuffedHeader.length} -> ${headerBytes.length}`, nameSize: `${stuffedEventName.length} -> ${eventNameBytes.length}`, dataSize: `${stuffedEventData.length} -> ${eventDataBytes.length}` }); const header = this.deserializeHeader(headerBytes); const eventName = this.bytesToString(eventNameBytes); const eventData = this.bytesToString(eventDataBytes); if (this.options.strictValidation) { this.validateHeader(header); this.validateEventName(eventName); this.validateEventData(eventData); } this.logger.info("Message decoded successfully", { eventName, messageId: header.messageId, senderId: header.senderId, receiverId: header.receiverId, eventDataLength: eventData.length }); return { header, eventName, eventData }; } catch (error) { this.logger.error("Message decoding failed", { error: error instanceof Error ? error.message : String(error), messageLength: data.length, cause: error instanceof Error ? error.name : "Unknown", hex: hexDump(data, 64) }); if (error instanceof InvalidMessageError) throw error; throw new DecodingError("Failed to decode EventMsgV3 message", { context: { messageLength: data.length }, cause: error instanceof Error ? error : new Error(String(error)) }); } } /** * Find a control character in data, skipping escaped instances */ findControlChar(data, target, startIndex) { let escaped = false; for (let i = startIndex; i < data.length; i++) { const byte = data[i]; if (escaped) escaped = false; else if (byte === CONTROL_CHARS.ESC) escaped = true; else if (byte === target) return i; } return -1; } /** * Serialize message header to 7 bytes (big-endian messageId) */ serializeHeader(header) { const bytes = new Uint8Array(7); bytes[0] = header.senderId; bytes[1] = header.receiverId; bytes[2] = header.senderGroupId; bytes[3] = header.receiverGroupId; bytes[4] = header.flags; bytes[5] = header.messageId >> 8 & 255; bytes[6] = header.messageId & 255; return bytes; } /** * Deserialize 7 bytes to message header (big-endian messageId) */ deserializeHeader(bytes) { if (bytes.length !== 7) throw new InvalidMessageError(`Header must be exactly 7 bytes, got ${bytes.length}`, { context: { headerLength: bytes.length } }); return { senderId: bytes[0], receiverId: bytes[1], senderGroupId: bytes[2], receiverGroupId: bytes[3], flags: bytes[4], messageId: bytes[5] << 8 | bytes[6] }; } /** * Convert string to bytes using configured encoding */ stringToBytes(str) { if (this.encoding === "utf8") return new TextEncoder().encode(str); const bytes = new Uint8Array(str.length); for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 255; return bytes; } /** * Convert bytes to string using configured encoding */ bytesToString(bytes) { if (this.encoding === "utf8") return new TextDecoder("utf8").decode(bytes); return String.fromCharCode(...bytes); } /** * Validate message header fields */ validateHeader(header) { const fields = [ { name: "senderId", value: header.senderId }, { name: "receiverId", value: header.receiverId }, { name: "senderGroupId", value: header.senderGroupId }, { name: "receiverGroupId", value: header.receiverGroupId }, { name: "flags", value: header.flags } ]; for (const field of fields) if (field.value < 0 || field.value > 255 || !Number.isInteger(field.value)) throw new MessageSizeError(field.value, 255, field.name, { context: { validRange: "0-255" } }); if (header.messageId < 0 || header.messageId > 65535 || !Number.isInteger(header.messageId)) throw new MessageSizeError(header.messageId, 65535, "messageId", { context: { validRange: "0-65535" } }); } /** * Validate event name length */ validateEventName(eventName) { const bytes = this.stringToBytes(eventName); if (bytes.length > this.options.maxEventNameLength) throw new MessageSizeError(bytes.length, this.options.maxEventNameLength, "eventName"); } /** * Validate event data length */ validateEventData(eventData) { const bytes = this.stringToBytes(eventData); if (bytes.length > this.options.maxEventDataLength) throw new MessageSizeError(bytes.length, this.options.maxEventDataLength, "eventData"); } }; //#endregion //#region src/types/base-transport.ts /** * Abstract base class for transport implementations. * * Provides common functionality like connection state management, error handling, * and configuration validation while allowing transport-specific implementations * through the template method pattern. * * @template TConfig - Transport-specific configuration type extending TransportConfig */ var BaseTransport = class extends EventEmitter { config; connectionState = "disconnected"; connectStartTime; constructor(config) { super(); this.validateConfig(config); this.config = { ...config }; } /** * Connect to the transport layer. * Handles state management and error wrapping around doConnect(). */ async connect() { if (this.connectionState === "connected") return; if (this.connectionState === "connecting") throw new ConnectionError("Connection already in progress", { context: this.getErrorContext("connect") }); this.connectionState = "connecting"; this.connectStartTime = /* @__PURE__ */ new Date(); try { await this.doConnect(); this.connectionState = "connected"; this.emit("connect"); } catch (error) { this.connectionState = "disconnected"; this.connectStartTime = void 0; throw new ConnectionError(`Failed to connect via ${this.constructor.name}`, { cause: error instanceof Error ? error : new Error(String(error)), context: this.getErrorContext("connect") }); } } /** * Disconnect from the transport layer. * Handles state management and error wrapping around doDisconnect(). */ async disconnect() { if (this.connectionState === "disconnected") return; const wasConnected = this.connectionState === "connected"; try { await this.doDisconnect(); } catch (error) { throw new DisconnectionError(`Failed to disconnect from ${this.constructor.name}`, { cause: error instanceof Error ? error : new Error(String(error)), context: this.getErrorContext("disconnect") }); } finally { this.connectionState = "disconnected"; this.connectStartTime = void 0; if (wasConnected) this.emit("disconnect"); } } /** * Check if transport is currently connected. */ isConnected() { return this.connectionState === "connected"; } /** * Send raw binary data through the transport. * Validates state and data before calling doSend(). */ async send(data) { if (this.connectionState !== "connected") throw new SendError("Cannot send data: transport not connected", { context: this.getErrorContext("send", { dataSize: data.length }) }); if (!(data instanceof Uint8Array)) throw new ValidationError$1("Data must be a Uint8Array", { context: this.getErrorContext("send", { dataType: typeof data }) }); if (data.length === 0) throw new ValidationError$1("Cannot send empty data", { context: this.getErrorContext("send", { dataSize: 0 }) }); try { await this.doSend(data); } catch (error) { throw new SendError(`Failed to send ${data.length} bytes via ${this.constructor.name}`, { cause: error instanceof Error ? error : new Error(String(error)), context: this.getErrorContext("send", { dataSize: data.length }) }); } } /** * Get the local device address. */ getLocalAddress() { return this.config.localAddress; } /** * Get the local group address. */ getGroupAddress() { return this.config.groupAddress; } /** * Validate transport configuration. * @param config - Configuration to validate * @throws {ValidationError} If configuration is invalid */ validateConfig(config) { if (!config) throw new ValidationError$1("Transport configuration is required"); if (typeof config.localAddress !== "number") throw new ValidationError$1("localAddress must be a number", { context: { localAddress: config.localAddress } }); if (!Number.isInteger(config.localAddress) || config.localAddress < 0 || config.localAddress > 255) throw new ValidationError$1("localAddress must be an integer between 0-255", { context: { localAddress: config.localAddress } }); if (typeof config.groupAddress !== "number") throw new ValidationError$1("groupAddress must be a number", { context: { groupAddress: config.groupAddress } }); if (!Number.isInteger(config.groupAddress) || config.groupAddress < 0 || config.groupAddress > 255) throw new ValidationError$1("groupAddress must be an integer between 0-255", { context: { groupAddress: config.groupAddress } }); } /** * Generate error context for consistent error reporting. * @param operation - Operation being performed * @param additional - Additional context data * @returns Error context object */ getErrorContext(operation, additional = {}) { return { transportType: this.constructor.name, state: this.connectionState, operation, localAddress: this.config.localAddress, groupAddress: this.config.groupAddress, timestamp: (/* @__PURE__ */ new Date()).toISOString(), connectedSince: this.connectStartTime?.toISOString(), ...additional }; } /** * Emit a transport error event. * @param error - Error to emit */ emitError(error) { this.emit("error", error); } /** * Handle unexpected disconnection. * Should be called by transport implementations when connection is lost. */ handleUnexpectedDisconnect(reason) { const wasConnected = this.connectionState === "connected"; this.connectionState = "disconnected"; this.connectStartTime = void 0; if (wasConnected) { this.emit("disconnect"); if (reason) this.emitError(new DisconnectionError("Transport disconnected unexpectedly", { cause: reason, context: this.getErrorContext("unexpected_disconnect") })); } } }; //#endregion //#region src/types/events.ts /** * Internal event names used by EventMsg */ const INTERNAL_EVENTS = { MESSAGE: "message", CONNECT: "connect", DISCONNECT: "disconnect", ERROR: "error", SEND: "send" }; //#endregion //#region src/event-msg.ts /** * EventMsg - Main class for EventMsgV3 protocol communication * * Provides type-safe messaging with per-method generics, async event handling, * and transport abstraction. */ var EventMsg = class extends EventEmitter { transport; protocol; logger; messageCounter = 0; config; connectionState = false; connectTime; pendingWaits = /* @__PURE__ */ new Map(); stats; messageHandlers = /* @__PURE__ */ new Map(); constructor(config) { super(); this.config = { ...DEFAULT_CONFIG, ...config }; this.transport = config.transport; this.protocol = new Protocol({ ...this.config, transport: this.transport }); configureLogging(this.config.logging); this.logger = getLogger("CORE"); this.stats = { messagesSent: 0, messagesReceived: 0, uptime: 0, connected: false }; this.setupTransportHandlers(); } /** * Connect to the transport layer */ async connect() { try { if (this.connectionState) return; await this.transport.connect(); this.connectionState = true; this.connectTime = /* @__PURE__ */ new Date(); this.stats.connected = true; this.emit(INTERNAL_EVENTS.CONNECT); this.logger.success("Connected to transport", { localAddress: this.transport.getLocalAddress(), groupAddress: this.transport.getGroupAddress(), uptime: 0 }); } catch (error) { this.logger.error("Failed to connect to transport", { error: error instanceof Error ? error.message : String(error), localAddress: this.transport.getLocalAddress(), groupAddress: this.transport.getGroupAddress(), cause: error instanceof Error ? error.name : "Unknown" }); const transportError = new ConnectionError("Failed to connect to transport", { context: { localAddress: this.transport.getLocalAddress(), groupAddress: this.transport.getGroupAddress() }, cause: error instanceof Error ? error : new Error(String(error)) }); this.emit(INTERNAL_EVENTS.ERROR, transportError); throw transportError; } } /** * Disconnect from the transport layer */ async disconnect() { try { if (!this.connectionState) return; this.clearAllPendingWaits(); this.offMessage(); await this.transport.disconnect(); this.connectionState = false; this.stats.connected = false; this.emit(INTERNAL_EVENTS.DISCONNECT); this.logger.info("Disconnected from transport", { uptime: this.connectTime ? Date.now() - this.connectTime.getTime() : 0, messagesSent: this.stats.messagesSent, messagesReceived: this.stats.messagesReceived }); } catch (error) { this.logger.error("Failed to disconnect from transport", { error: error instanceof Error ? error.message : String(error), cause: error instanceof Error ? error.name : "Unknown", uptime: this.connectTime ? Date.now() - this.connectTime.getTime() : 0 }); const transportError = new DisconnectionError("Failed to disconnect from transport", { cause: error instanceof Error ? error : new Error(String(error)) }); this.emit(INTERNAL_EVENTS.ERROR, transportError); throw transportError; } } /** * Check if currently connected */ isConnected() { return this.connectionState && this.transport.isConnected(); } /** * Send a message with explicit receiver targeting */ async send(event, data, options) { try { if (!this.isConnected()) throw new ConnectionError("Not connected to transport"); this.validateSendOptions(options); const header = this.createHeader(options); const eventData = data; const encodedMessage = this.protocol.encode(header, event, eventData); this.logger.debug("Message encoded", { event, header, dataLength: eventData.length, encodedSize: encodedMessage.length }); this.logger.trace("Encoded message hex dump", { hex: hexDump(encodedMessage) }); await this.transport.send(encodedMessage); this.stats.messagesSent++; this.logger.info("Message sent", { event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, messageId: header.messageId, size: encodedMessage.length, totalSent: this.stats.messagesSent }); this.emit(INTERNAL_EVENTS.SEND, { event, data, options, header, messageSize: encodedMessage.length }); } catch (error) { this.logger.error("Failed to send message", { error: error instanceof Error ? error.message : String(error), event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, dataLength: JSON.stringify(data).length, cause: error instanceof Error ? error.name : "Unknown" }); const sendError = new SendError("Failed to send message", { context: { event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, dataLength: JSON.stringify(data).length }, cause: error instanceof Error ? error : new Error(String(error)) }); this.emit(INTERNAL_EVENTS.ERROR, sendError); throw sendError; } } /** * Send binary data with explicit receiver targeting */ async sendBinary(event, data, options) { try { if (!this.isConnected()) throw new ConnectionError("Not connected to transport"); this.validateSendOptions(options); const header = this.createHeader(options); const encodedMessage = this.protocol.encodeBinary(header, event, data); this.logger.debug("Binary message encoded", { event, header, dataLength: data.length, encodedSize: encodedMessage.length }); this.logger.trace("Encoded binary message hex dump", { hex: hexDump(encodedMessage) }); await this.transport.send(encodedMessage); this.stats.messagesSent++; this.logger.info("Binary message sent", { event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, messageId: header.messageId, size: encodedMessage.length, totalSent: this.stats.messagesSent }); this.emit(INTERNAL_EVENTS.SEND, { event, data, options, header, messageSize: encodedMessage.length }); } catch (error) { this.logger.error("Failed to send binary message", { error: error instanceof Error ? error.message : String(error), event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, dataLength: data.length, cause: error instanceof Error ? error.name : "Unknown" }); const sendError = new SendError("Failed to send binary message", { context: { event, receiverId: options.receiverId, receiverGroupId: options.receiverGroupId, dataLength: data.length }, cause: error instanceof Error ? error : new Error(String(error)) }); this.emit(INTERNAL_EVENTS.ERROR, sendError); throw sendError; } } /** * Register a type-safe event handler */ on(event, handler) { return super.on(event, handler); } /** * Register a one-time type-safe event handler */ once(event, handler) { return super.once(event, handler); } /** * Remove a type-safe event handler */ off(event, handler) { if (handler) return super.off(event, handler); return super.removeAllListeners(event); } onMessage(eventNameOrHandler, handler) { if (typeof eventNameOrHandler === "string") { const targetEventName = eventNameOrHandler; const specificHandler = handler; const internalHandler = (messageInfo) => { if (messageInfo.eventName === targetEventName) specificHandler(messageInfo.data, messageInfo.metadata); }; this.on(INTERNAL_EVENTS.MESSAGE, internalHandler); this.trackMessageHandler(targetEventName, internalHandler); } else { const catchAllHandler = eventNameOrHandler; const internalHandler = (messageInfo) => { catchAllHandler(messageInfo.eventName, messageInfo.data, messageInfo.metadata); }; this.on(INTERNAL_EVENTS.MESSAGE, internalHandler); this.trackMessageHandler(Symbol("catch-all"), internalHandler); } return this; } /** * Remove a message handler * @param eventName - The event name to stop listening for (optional for catch-all handlers) * @returns this instance for chaining * @example * ```typescript * // Remove specific event handlers * eventMsg.offMessage('ping'); * * // Remove all message handlers * eventMsg.offMessage(); * ``` */ offMessage(eventName) { if (eventName) { const handlers = this.messageHandlers.get(eventName); if (handlers) { for (const handler of handlers) this.off(INTERNAL_EVENTS.MESSAGE, handler); this.messageHandlers.delete(eventName); } } else { for (const [, handlers] of this.messageHandlers) for (const handler of handlers) this.off(INTERNAL_EVENTS.MESSAGE, handler); this.messageHandlers.clear(); } return this; } /** * Track message handlers for proper cleanup */ trackMessageHandler(key, handler) { if (!this.messageHandlers.has(key)) this.messageHandlers.set(key, []); this.messageHandlers.get(key).push(handler); } /** * Wait for a single event occurrence with timeout */ waitFor(event, options = {}) { return new Promise((resolve, reject) => { const timeout = options.timeout ?? this.config.messageTimeout; const pendingWait = { resolve, reject, timeout: setTimeout(() => { this.removePendingWait(event, pendingWait); reject(new WaitForTimeoutError(event, timeout)); }, timeout), filter: options.filter ?? void 0 }; this.addPendingWait(event, pendingWait); this.logger.debug("Waiting for event", { event, timeout, hasFilter: !!options.filter, pendingWaits: this.pendingWaits.get(event)?.length ?? 0 }); }); } /** * Get local device address from transport */ getLocalAddress() { return this.transport.getLocalAddress(); } /** * Get local group address from transport */ getGroupAddress() { return this.transport.getGroupAddress(); } /** * Get connection statistics */ getStats() { return { ...this.stats, uptime: this.connectTime ? Date.now() - this.connectTime.getTime() : 0 }; } /** * Create message header from send options */ createHeader(options) { return { senderId: this.transport.getLocalAddress(), receiverId: options.receiverId, senderGroupId: this.transport.getGroupAddress(), receiverGroupId: options.receiverGroupId ?? 0, flags: options.flags ?? 0, messageId: ++this.messageCounter & 65535 }; } /** * Validate send options */ validateSendOptions(options) { if (options.receiverId < 0 || options.receiverId > 255 || !Number.isInteger(options.receiverId)) throw new AddressValidationError(options.receiverId, "receiverId"); if (options.receiverGroupId !== void 0 && (options.receiverGroupId < 0 || options.receiverGroupId > 255 || !Number.isInteger(options.receiverGroupId))) throw new AddressValidationError(options.receiverGroupId, "receiverGroupId"); if (options.flags !== void 0 && (options.flags < 0 || options.flags > 255 || !Number.isInteger(options.flags))) throw new ValidationError$1("flags must be 0-255", { field: "flags", value: options.flags }); } /** * Setup transport event handlers */ setupTransportHandlers() { this.transport.on("data", this.handleIncomingData.bind(this)); this.transport.on("error", this.handleTransportError.bind(this)); this.transport.on("disconnect", this.handleTransportDisconnect.bind(this)); } /** * Handle incoming transport data */ handleIncomingData(data) { try { const decoded = this.protocol.decode(data); const metadata = { senderId: decoded.header.senderId, senderGroupId: decoded.header.senderGroupId, receiverId: decoded.header.receiverId, receiverGroupId: decoded.header.receiverGroupId, messageId: decoded.header.messageId, flags: decoded.header.flags, timestamp: /* @__PURE__ */ new Date() }; let eventData; try { eventData = JSON.parse(decoded.eventData); } catch { eventData = decoded.eventData; } const messageResult = { data: eventData, metadata }; this.stats.messagesReceived++; this.logger.info("Message received", { event: decoded.eventName, senderId: metadata.senderId, senderGroupId: metadata.senderGroupId, messageId: metadata.messageId, size: data.length, totalReceived: this.stats.messagesReceived }); this.logger.trace("Received message hex dump", { hex: hexDump(data) }); this.resolvePendingWaits(decoded.eventName, messageResult); const messageInfo = { eventName: decoded.eventName, data: eventData, metadata, messageSize: data.length }; this.emit(INTERNAL_EVENTS.MESSAGE, messageInfo); } catch (error) { this.logger.error("Failed to process incoming message", { error: error instanceof Error ? error.message : String(error), messageLength: data.length, cause: error instanceof Error ? error.name : "Unknown", hex: hexDump(data, 64) }); const protocolError = new TransportError("Failed to process incoming message", { context: { messageLength: data.length }, cause: error instanceof Error ? error : new Error(String(error)) }); this.emit(INTERNAL_EVENTS.ERROR, protocolError); } } /** * Handle transport errors */ handleTransportError(error) { this.logger.error("Transport error occurred", { error: error.message, cause: error.name, connected: this.connectionState, uptime: this.connectTime ? Date.now() - this.connectTime.getTime() : 0 }); const transportError = new TransportError("Transport error occurred", { cause: error }); this.stats.lastError = /* @__PURE__ */ new Date(); this.emit(INTERNAL_EVENTS.ERROR, transportError); } /** * Handle transport disconnection */ handleTransportDisconnect() { const uptime = this.connectTime ? Date.now() - this.connectTime.getTime() : 0; this.logger.warn("Transport disconnected unexpectedly", { uptime, messagesSent: this.stats.messagesSent, messagesReceived: this.stats.messagesReceived, pendingWaits: this.pendingWaits.size }); this.connectionState = false; this.connectTime = void 0; this.stats.connected = false; this.clearAllPendingWaits(); this.emit(INTERNAL_EVENTS.DISCONNECT); } /** * Add a pending wait for an event */ addPendingWait(event, pendingWait) { if (!this.pendingWaits.has(event)) this.pendingWaits.set(event, []); this.pendingWaits.get(event)?.push(pendingWait); } /** * Remove a pending wait */ removePendingWait(event, pendingWait) { const waits = this.pendingWaits.get(event); if (waits) { const index = waits.indexOf(pendingWait); if (index !== -1) { clearTimeout(pendingWait.timeout); waits.splice(index, 1); if (waits.length === 0) this.pendingWaits.delete(event); } } } /** * Resolve pending waits for an event */ resolvePendingWaits(event, messageResult) { const waits = this.pendingWaits.get(event); if (!waits || waits.length === 0) return; const waitsToResolve = [...waits]; for (const wait of waitsToResolve) { if (wait.filter && !wait.filter(messageResult.metadata)) continue; this.removePendingWait(event, wait); wait.resolve(messageResult); } } /** * Clear all pending waits (on disconnect) */ clearAllPendingWaits() { for (const [, waits] of this.pendingWaits.entries()) for (const wait of waits) { clearTimeout(wait.timeout); wait.reject(new DisconnectionError("Connection lost while waiting for event")); } this.pendingWaits.clear(); } }; //#endregion export { AddressValidationError, BaseTransport, CONTROL_CHARS, ConnectionError, ConnectionTimeoutError, DEFAULT_CONFIG, DEFAULT_LOGGING_CONFIG, DEFAULT_PROTOCOL_OPTIONS, DecodingError, DisconnectionError, EncodingError, EventMsg, EventMsgError, INTERNAL_EVENTS, InvalidMessageError, LOG_NAMESPACES, MessageSizeError, Protocol, ProtocolError, ValidationError as ProtocolValidationError, SendError, SendTimeoutError, TimeoutError, TransportError, ValidationError$1 as ValidationError, WaitForTimeoutError, getLogger, getMaxStuffedSize, getMinUnstuffedSize, hexDump, isValidStuffing, needsStuffing, stuff, unstuff }; //# sourceMappingURL=index.js.map