@eventmsg/core
Version:
EventMsgV3 TypeScript library - Core protocol implementation with transport abstraction
1 lines • 99.8 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","names":["message: string","code: string","options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","message: string","options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","options: {\n context?: Record<string, unknown>;\n cause?: Error;\n }","message: string","timeoutMs: number","options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","options: {\n context?: Record<string, unknown>;\n cause?: Error;\n }","eventName: string","message: string","options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","options: {\n context?: Record<string, unknown>;\n cause?: Error;\n }","ValidationError","message: string","options: {\n field?: string;\n value?: unknown;\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","address: number","options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n }","size: number","maxSize: number","DEFAULT_LOGGING_CONFIG: LoggingConfig","globalConsola: ReturnType<typeof createConsola>","config: LoggingConfig","namespace: keyof typeof LOG_NAMESPACES","data: Uint8Array","byte: number","data: Uint8Array","result: number[]","originalSize: number","stuffedSize: number","DEFAULT_PROTOCOL_OPTIONS: Required<ProtocolOptions>","DEFAULT_CONFIG: Required<Omit<EventMsgConfig, 'transport'>>","config: EventMsgConfig","header: MessageHeader","eventName: string","eventData: string","eventData: Uint8Array","data: Uint8Array","target: number","startIndex: number","bytes: Uint8Array","str: string","EventEmitter","config: TConfig","data: Uint8Array","ValidationError","operation: string","additional: Record<string, unknown>","error: Error","reason?: Error","EventEmitter","config: EventMsgConfig","event: string","data: TData","options: SendOptions","data: Uint8Array","event: string | symbol","handler: MessageHandler<TData>","handler?: MessageHandler<TData>","eventNameOrHandler:\n | string\n | ((eventName: string, data: unknown, metadata: MessageMetadata) => void)","handler?: (data: TData, metadata: MessageMetadata) => void","messageInfo: MessageInfo","eventName?: string","key: string | symbol","handler: (messageInfo: MessageInfo) => void","options: WaitForOptions","pendingWait: PendingWait<TData>","ValidationError","metadata: MessageMetadata","eventData: unknown","messageResult: MessageResult<unknown>","error: Error"],"sources":["../src/errors/event-msg-error.ts","../src/errors/protocol-error.ts","../src/errors/timeout-error.ts","../src/errors/transport-error.ts","../src/errors/validation-error.ts","../src/types/logger.ts","../src/internal/logger.ts","../src/internal/byte-stuffing.ts","../src/types/config.ts","../src/protocol.ts","../src/types/base-transport.ts","../src/types/events.ts","../src/event-msg.ts"],"sourcesContent":["/**\n * Base error class for all EventMsg-related errors.\n * Provides consistent error handling with context and solutions.\n */\nexport class EventMsgError extends Error {\n /** Error code for programmatic handling */\n code: string;\n\n /** Additional context about the error */\n readonly context: Record<string, unknown> | undefined;\n\n /** Suggested solutions for the error */\n readonly solutions: string[];\n\n /** Original error that caused this error */\n readonly cause: Error | undefined;\n\n constructor(\n message: string,\n code: string,\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(message);\n\n this.name = this.constructor.name;\n this.code = code;\n this.context = options.context;\n this.solutions = options.solutions || [];\n this.cause = options.cause;\n\n // Maintain proper stack trace in V8\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, this.constructor);\n }\n }\n\n /**\n * Get a detailed error message including context and solutions\n */\n getDetailedMessage(): string {\n let details = `${this.name}: ${this.message}`;\n\n if (this.code) {\n details += ` (${this.code})`;\n }\n\n if (this.context && Object.keys(this.context).length > 0) {\n details += '\\nContext:';\n for (const [key, value] of Object.entries(this.context)) {\n details += `\\n ${key}: ${JSON.stringify(value)}`;\n }\n }\n\n if (this.solutions.length > 0) {\n details += '\\nSuggested solutions:';\n for (const solution of this.solutions) {\n details += `\\n • ${solution}`;\n }\n }\n\n if (this.cause) {\n details += `\\nCaused by: ${this.cause.message}`;\n }\n\n return details;\n }\n\n /**\n * Convert error to JSON for logging/serialization\n */\n toJSON(): Record<string, unknown> {\n return {\n name: this.name,\n message: this.message,\n code: this.code,\n context: this.context,\n solutions: this.solutions,\n stack: this.stack,\n cause: this.cause\n ? {\n name: this.cause.name,\n message: this.cause.message,\n }\n : undefined,\n };\n }\n}\n","import { EventMsgError } from './event-msg-error.js';\n\n/**\n * Error related to EventMsgV3 protocol operations (encoding, decoding, validation)\n */\nexport class ProtocolError extends EventMsgError {\n constructor(\n message: string,\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(message, 'PROTOCOL_ERROR', {\n ...options,\n solutions: options.solutions || [\n 'Check message format',\n 'Verify protocol specification compliance',\n 'Review message content',\n ],\n });\n }\n}\n\n/**\n * Message encoding failed\n */\nexport class EncodingError extends ProtocolError {\n constructor(\n message = 'Failed to encode message',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check event name length (≤64 bytes)',\n 'Check event data length (≤3048 bytes)',\n 'Verify data is JSON serializable',\n 'Check for invalid characters',\n ],\n });\n\n this.code = 'ENCODING_ERROR';\n }\n}\n\n/**\n * Message decoding failed\n */\nexport class DecodingError extends ProtocolError {\n constructor(\n message = 'Failed to decode message',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check message framing (SOH, STX, US, EOT)',\n 'Verify byte stuffing is correct',\n 'Check for data corruption',\n 'Ensure sender uses same protocol version',\n ],\n });\n\n this.code = 'DECODING_ERROR';\n }\n}\n\n/**\n * Message validation failed\n */\nexport class ValidationError extends ProtocolError {\n constructor(\n message = 'Message validation failed',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check address ranges (0-255)',\n 'Verify message size limits',\n 'Check required fields are present',\n 'Validate data types',\n ],\n });\n\n this.code = 'VALIDATION_ERROR';\n }\n}\n\n/**\n * Invalid message format\n */\nexport class InvalidMessageError extends ProtocolError {\n constructor(\n message = 'Invalid message format',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check message starts with SOH (0x01)',\n 'Check message ends with EOT (0x04)',\n 'Verify minimum message length (10 bytes)',\n 'Check header is exactly 7 bytes after unstuffing',\n ],\n });\n\n this.code = 'INVALID_MESSAGE_ERROR';\n }\n}\n","import { EventMsgError } from './event-msg-error.js';\n\n/**\n * Error for operations that exceed their timeout duration\n */\nexport class TimeoutError extends EventMsgError {\n /** Timeout duration in milliseconds */\n readonly timeoutMs: number;\n\n /** Operation that timed out */\n readonly operation: string;\n\n constructor(\n message: string,\n timeoutMs: number,\n operation = 'operation',\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(message, 'TIMEOUT_ERROR', {\n ...options,\n context: {\n ...options.context,\n timeoutMs,\n operation,\n },\n solutions: [\n 'Increase timeout duration',\n 'Check network connectivity',\n 'Verify target device is responding',\n 'Reduce message frequency',\n ],\n });\n\n this.timeoutMs = timeoutMs;\n this.operation = operation;\n }\n}\n\n/**\n * Send operation timed out\n */\nexport class SendTimeoutError extends TimeoutError {\n constructor(\n timeoutMs: number,\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(`Send operation timed out after ${timeoutMs}ms`, timeoutMs, 'send', {\n ...options,\n solutions: [\n 'Increase send timeout',\n 'Check transport connection',\n 'Verify target device is reachable',\n 'Check for network congestion',\n ],\n });\n\n this.code = 'SEND_TIMEOUT_ERROR';\n }\n}\n\n/**\n * WaitFor operation timed out\n */\nexport class WaitForTimeoutError extends TimeoutError {\n /** Event name that was being waited for */\n readonly eventName: string;\n\n constructor(\n eventName: string,\n timeoutMs: number,\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(\n `waitFor('${eventName}') timed out after ${timeoutMs}ms`,\n timeoutMs,\n 'waitFor',\n {\n ...options,\n context: {\n ...options.context,\n eventName,\n },\n solutions: [\n 'Increase waitFor timeout',\n 'Check if event name is correct',\n 'Verify sender is responding',\n 'Check event filter conditions',\n ],\n }\n );\n\n this.eventName = eventName;\n this.code = 'WAIT_FOR_TIMEOUT_ERROR';\n }\n}\n\n/**\n * Connection timeout error\n */\nexport class ConnectionTimeoutError extends TimeoutError {\n constructor(\n timeoutMs: number,\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(\n `Connection attempt timed out after ${timeoutMs}ms`,\n timeoutMs,\n 'connect',\n {\n ...options,\n solutions: [\n 'Increase connection timeout',\n 'Check device availability',\n 'Verify connection parameters',\n 'Check for interference',\n ],\n }\n );\n\n this.code = 'CONNECTION_TIMEOUT_ERROR';\n }\n}\n","import { EventMsgError } from './event-msg-error.js';\n\n/**\n * Error related to transport layer operations (connection, sending, receiving)\n */\nexport class TransportError extends EventMsgError {\n constructor(\n message: string,\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(message, 'TRANSPORT_ERROR', {\n ...options,\n solutions: options.solutions || [\n 'Check transport connection',\n 'Verify transport configuration',\n 'Retry the operation',\n ],\n });\n }\n}\n\n/**\n * Transport connection failed\n */\nexport class ConnectionError extends TransportError {\n constructor(\n message = 'Failed to connect to transport',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check if the device is available',\n 'Verify connection parameters',\n 'Ensure no other process is using the transport',\n 'Check device permissions',\n ],\n });\n\n this.code = 'CONNECTION_ERROR';\n }\n}\n\n/**\n * Transport send operation failed\n */\nexport class SendError extends TransportError {\n constructor(\n message = 'Failed to send data through transport',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check transport connection status',\n 'Verify message size is within limits',\n 'Retry sending the message',\n 'Check for transport buffer overflow',\n ],\n });\n\n this.code = 'SEND_ERROR';\n }\n}\n\n/**\n * Transport disconnection error\n */\nexport class DisconnectionError extends TransportError {\n constructor(\n message = 'Transport disconnected unexpectedly',\n options: {\n context?: Record<string, unknown>;\n cause?: Error;\n } = {}\n ) {\n super(message, {\n ...options,\n solutions: [\n 'Check physical connection',\n 'Implement reconnection logic',\n 'Monitor connection status',\n 'Handle graceful disconnection',\n ],\n });\n\n this.code = 'DISCONNECTION_ERROR';\n }\n}\n","import { EventMsgError } from './event-msg-error.js';\n\n/**\n * Error for input validation failures\n */\nexport class ValidationError extends EventMsgError {\n /** Field that failed validation */\n readonly field: string | undefined;\n\n /** Value that was invalid */\n readonly value: unknown;\n\n constructor(\n message: string,\n options: {\n field?: string;\n value?: unknown;\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(message, 'VALIDATION_ERROR', {\n ...options,\n context: {\n ...options.context,\n field: options.field,\n value: options.value,\n },\n solutions: [\n 'Check input parameters',\n 'Verify value ranges and types',\n 'Review API documentation',\n ],\n });\n\n this.field = options.field;\n this.value = options.value;\n }\n}\n\n/**\n * Address validation error (0-255 range)\n */\nexport class AddressValidationError extends ValidationError {\n constructor(\n address: number,\n field = 'address',\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(`Invalid ${field}: ${address}. Must be 0-255`, {\n ...options,\n field,\n value: address,\n solutions: [\n 'Use address in range 0-255',\n 'Check for negative values',\n 'Verify address is an integer',\n ],\n });\n\n this.code = 'ADDRESS_VALIDATION_ERROR';\n }\n}\n\n/**\n * Message size validation error\n */\nexport class MessageSizeError extends ValidationError {\n constructor(\n size: number,\n maxSize: number,\n field = 'message',\n options: {\n context?: Record<string, unknown>;\n solutions?: string[];\n cause?: Error;\n } = {}\n ) {\n super(`${field} size ${size} bytes exceeds maximum ${maxSize} bytes`, {\n ...options,\n field,\n value: size,\n context: {\n ...options.context,\n maxSize,\n },\n solutions: [\n `Reduce ${field} size to ${maxSize} bytes or less`,\n 'Consider splitting large messages',\n 'Use more efficient encoding',\n ],\n });\n\n this.code = 'MESSAGE_SIZE_ERROR';\n }\n}\n","/**\n * Simple logging configuration for Consola\n */\nexport interface LoggingConfig {\n /**\n * Enable/disable logging\n * @default true\n */\n enabled?: boolean;\n\n /**\n * Consola log level (0-5)\n * 0=Error, 1=Warn, 2=Log, 3=Info, 4=Debug, 5=Trace\n * @default 3\n */\n level?: number;\n}\n\n/**\n * Default logging configuration\n */\nexport const DEFAULT_LOGGING_CONFIG: LoggingConfig = {\n enabled: true,\n level: 3, // Info level\n};\n\n/**\n * Log namespaces for tagging\n */\nexport const LOG_NAMESPACES = {\n CORE: 'eventmsg:core',\n PROTOCOL: 'eventmsg:protocol',\n TRANSPORT: 'eventmsg:transport',\n TRANSPORT_WEBBLE: 'eventmsg:transport:webble',\n} as const;\n","import { createConsola } from 'consola/basic';\nimport type { LoggingConfig } from '../types/logger.js';\nimport { DEFAULT_LOGGING_CONFIG, LOG_NAMESPACES } from '../types/logger.js';\n\n// Global consola instance\nlet globalConsola: ReturnType<typeof createConsola> = createConsola({\n level: DEFAULT_LOGGING_CONFIG.level ?? 3,\n});\n\n/**\n * Configure global logging\n */\nexport function configureLogging(config: LoggingConfig): void {\n const finalConfig = { ...DEFAULT_LOGGING_CONFIG, ...config };\n\n if (finalConfig.enabled && finalConfig.level !== -999) {\n globalConsola = createConsola({ level: finalConfig.level ?? 3 });\n } else {\n // Use Consola's silent mode - level 0 with no reporters\n globalConsola = createConsola({\n level: 0,\n reporters: [], // No reporters = no output\n });\n }\n}\n\n/**\n * Get logger for a component\n */\nexport function getLogger(\n namespace: keyof typeof LOG_NAMESPACES\n): ReturnType<typeof globalConsola.withTag> {\n return globalConsola.withTag(LOG_NAMESPACES[namespace]);\n}\n\n/**\n * Simple hex dump for debugging\n */\nexport function hexDump(data: Uint8Array, maxBytes = 64): string {\n const limited = data.length > maxBytes ? data.slice(0, maxBytes) : data;\n const hex = Array.from(limited)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join(' ');\n\n if (data.length > maxBytes) {\n return `${hex} ... (${data.length - maxBytes} more bytes)`;\n }\n return hex;\n}\n","/**\n * EventMsgV3 Byte Stuffing Implementation\n *\n * Implements the exact byte stuffing algorithm from the C implementation\n * to ensure protocol compatibility.\n */\n\n/**\n * Control characters used in EventMsgV3 protocol\n */\nexport const CONTROL_CHARS = {\n SOH: 0x01, // Start of Header\n STX: 0x02, // Start of Text\n EOT: 0x04, // End of Transmission\n US: 0x1f, // Unit Separator\n ESC: 0x1b, // Escape Character\n} as const;\n\n/**\n * XOR mask for byte stuffing\n */\nconst STUFF_MASK = 0x20;\n\n/**\n * Check if a byte is a control character that needs stuffing\n */\nfunction isControlChar(byte: number): boolean {\n return (\n byte === CONTROL_CHARS.SOH ||\n byte === CONTROL_CHARS.STX ||\n byte === CONTROL_CHARS.EOT ||\n byte === CONTROL_CHARS.US ||\n byte === CONTROL_CHARS.ESC\n );\n}\n\n/**\n * Stuff (encode) bytes by escaping control characters\n *\n * Algorithm:\n * - If byte is a control character: Insert ESC, then XOR byte with 0x20\n * - Otherwise: Insert byte as-is\n *\n * @param data Input data to stuff\n * @returns Stuffed data\n */\nexport function stuff(data: Uint8Array): Uint8Array {\n const result: number[] = [];\n\n for (let i = 0; i < data.length; i++) {\n const byte = data[i]!;\n\n if (isControlChar(byte)) {\n // Escape the control character\n result.push(CONTROL_CHARS.ESC);\n result.push(byte ^ STUFF_MASK);\n } else {\n // Normal byte, no stuffing needed\n result.push(byte);\n }\n }\n\n return new Uint8Array(result);\n}\n\n/**\n * Unstuff (decode) bytes by handling escaped characters\n *\n * Algorithm:\n * - If byte is ESC: Mark next byte as escaped, continue\n * - If previous byte was ESC: XOR current byte with 0x20, add to output\n * - Otherwise: Add byte as-is to output\n *\n * @param data Stuffed data to unstuff\n * @returns Unstuffed data\n * @throws {Error} If data contains invalid escape sequences\n */\nexport function unstuff(data: Uint8Array): Uint8Array {\n const result: number[] = [];\n let escaped = false;\n\n for (let i = 0; i < data.length; i++) {\n const byte = data[i]!;\n\n if (escaped) {\n // Previous byte was ESC, so this byte is escaped\n result.push(byte ^ STUFF_MASK);\n escaped = false;\n } else if (byte === CONTROL_CHARS.ESC) {\n // This is an escape character, next byte will be escaped\n escaped = true;\n } else {\n // Normal byte\n result.push(byte);\n }\n }\n\n // Check for incomplete escape sequence\n if (escaped) {\n throw new Error(\n 'Invalid byte stuffing: data ends with incomplete escape sequence'\n );\n }\n\n return new Uint8Array(result);\n}\n\n/**\n * Calculate the maximum possible size after stuffing\n * In worst case, every byte could be a control character, doubling the size\n *\n * @param originalSize Original data size\n * @returns Maximum size after stuffing\n */\nexport function getMaxStuffedSize(originalSize: number): number {\n return originalSize * 2; // Worst case: every byte is stuffed\n}\n\n/**\n * Calculate the minimum possible size after unstuffing\n * In best case, no bytes are stuffed\n *\n * @param stuffedSize Stuffed data size\n * @returns Minimum size after unstuffing\n */\nexport function getMinUnstuffedSize(stuffedSize: number): number {\n return Math.floor(stuffedSize / 2); // Worst case: every pair is stuffed\n}\n\n/**\n * Test if data contains any control characters that would need stuffing\n *\n * @param data Data to test\n * @returns True if data contains control characters\n */\nexport function needsStuffing(data: Uint8Array): boolean {\n for (let i = 0; i < data.length; i++) {\n if (isControlChar(data[i]!)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Validate that stuffed data has proper escape sequences\n *\n * @param data Stuffed data to validate\n * @returns True if data has valid stuffing\n */\nexport function isValidStuffing(data: Uint8Array): boolean {\n let escaped = false;\n\n for (let i = 0; i < data.length; i++) {\n const byte = data[i]!;\n\n if (escaped) {\n // Previous byte was ESC, this byte should be valid escaped value\n const unescaped = byte ^ STUFF_MASK;\n if (!isControlChar(unescaped)) {\n return false; // Invalid escape sequence\n }\n escaped = false;\n } else if (byte === CONTROL_CHARS.ESC) {\n escaped = true;\n } else if (isControlChar(byte)) {\n return false; // Unescaped control character\n }\n }\n\n return !escaped; // Should not end with incomplete escape\n}\n","import type { LoggingConfig } from './logger.js';\nimport { DEFAULT_LOGGING_CONFIG } from './logger.js';\nimport type { Transport } from './transport.js';\n\n/**\n * Configuration for EventMsg class\n */\nexport interface EventMsgConfig {\n /** Transport instance (handles addressing and connection) */\n transport: Transport;\n\n /** Maximum message size in bytes after stuffing (default: 4096) */\n maxMessageSize?: number;\n\n /** Default timeout for operations in milliseconds (default: 5000) */\n messageTimeout?: number;\n\n /** Text encoding for message data (default: 'utf8') */\n encoding?: 'utf8' | 'binary';\n\n /** Logging configuration (replaces debug option) */\n logging?: LoggingConfig;\n\n /** Protocol-specific options */\n protocol?: ProtocolOptions;\n}\n\n/**\n * Protocol-specific configuration options\n */\nexport interface ProtocolOptions {\n /** Enable strict message validation (default: true) */\n strictValidation?: boolean;\n\n /** Maximum event name length in bytes (default: 64) */\n maxEventNameLength?: number;\n\n /** Maximum event data length in bytes (default: 3048) */\n maxEventDataLength?: number;\n}\n\n/**\n * Default protocol options\n */\nexport const DEFAULT_PROTOCOL_OPTIONS: Required<ProtocolOptions> = {\n strictValidation: true,\n maxEventNameLength: 64,\n maxEventDataLength: 3048,\n} as const;\n\n/**\n * Default configuration values\n */\nexport const DEFAULT_CONFIG: Required<Omit<EventMsgConfig, 'transport'>> = {\n maxMessageSize: 4096,\n messageTimeout: 5000,\n encoding: 'utf8',\n logging: DEFAULT_LOGGING_CONFIG,\n protocol: DEFAULT_PROTOCOL_OPTIONS,\n} as const;\n","import {\n DecodingError,\n EncodingError,\n InvalidMessageError,\n MessageSizeError,\n} from './errors/index.js';\nimport { CONTROL_CHARS, stuff, unstuff } from './internal/byte-stuffing.js';\nimport { getLogger, hexDump } from './internal/logger.js';\nimport type { ProtocolOptions } from './types/config.js';\nimport { DEFAULT_PROTOCOL_OPTIONS } from './types/config.js';\nimport type { EventMsgConfig, MessageHeader } from './types/index.js';\n\n/**\n * Decoded message structure\n */\nexport interface DecodedMessage {\n header: MessageHeader;\n eventName: string;\n eventData: string;\n}\n\n/**\n * EventMsgV3 Protocol Implementation\n *\n * Handles encoding and decoding of EventMsgV3 binary protocol messages.\n * Message format: [SOH][StuffedHeader][STX][StuffedEventName][US][StuffedEventData][EOT]\n */\nexport class Protocol {\n private readonly options: Required<ProtocolOptions>;\n private readonly encoding: 'utf8' | 'binary';\n private readonly logger: ReturnType<typeof getLogger>;\n\n constructor(config: EventMsgConfig) {\n this.options = { ...DEFAULT_PROTOCOL_OPTIONS, ...config.protocol };\n this.encoding = config.encoding ?? 'utf8';\n this.logger = getLogger('PROTOCOL');\n }\n\n /**\n * Encode a message into EventMsgV3 binary format\n *\n * @param header Message header (7 bytes)\n * @param eventName Event name string\n * @param eventData Event data string (JSON)\n * @returns Encoded binary message\n * @throws {EncodingError} If encoding fails\n * @throws {MessageSizeError} If message exceeds size limits\n */\n encode(\n header: MessageHeader,\n eventName: string,\n eventData: string\n ): Uint8Array {\n this.logger.debug('Starting message encode', {\n eventName,\n eventDataLength: eventData.length,\n header,\n encoding: this.encoding,\n });\n\n try {\n // Validate inputs\n this.validateHeader(header);\n this.validateEventName(eventName);\n this.validateEventData(eventData);\n\n // Convert strings to bytes\n const eventNameBytes = this.stringToBytes(eventName);\n const eventDataBytes = this.stringToBytes(eventData);\n\n // Serialize header (7 bytes, big-endian messageId)\n const headerBytes = this.serializeHeader(header);\n\n // Apply byte stuffing to each section\n const stuffedHeader = stuff(headerBytes);\n const stuffedEventName = stuff(eventNameBytes);\n const stuffedEventData = stuff(eventDataBytes);\n\n this.logger.trace('Byte stuffing complete', {\n headerSize: `${headerBytes.length} -> ${stuffedHeader.length}`,\n nameSize: `${eventNameBytes.length} -> ${stuffedEventName.length}`,\n dataSize: `${eventDataBytes.length} -> ${stuffedEventData.length}`,\n });\n\n // Calculate total message size\n const totalSize =\n 1 +\n stuffedHeader.length +\n 1 +\n stuffedEventName.length +\n 1 +\n stuffedEventData.length +\n 1;\n\n if (totalSize > this.options.maxEventDataLength + 1000) {\n // Allow some overhead\n throw new MessageSizeError(\n totalSize,\n this.options.maxEventDataLength + 1000,\n 'encoded message'\n );\n }\n\n // Build final message: [SOH][StuffedHeader][STX][StuffedEventName][US][StuffedEventData][EOT]\n const message = new Uint8Array(totalSize);\n let offset = 0;\n\n // SOH\n message[offset++] = CONTROL_CHARS.SOH;\n\n // Stuffed Header\n message.set(stuffedHeader, offset);\n offset += stuffedHeader.length;\n\n // STX\n message[offset++] = CONTROL_CHARS.STX;\n\n // Stuffed Event Name\n message.set(stuffedEventName, offset);\n offset += stuffedEventName.length;\n\n // US\n message[offset++] = CONTROL_CHARS.US;\n\n // Stuffed Event Data\n message.set(stuffedEventData, offset);\n offset += stuffedEventData.length;\n\n // EOT\n message[offset++] = CONTROL_CHARS.EOT;\n\n const finalMessage = message.subarray(0, offset);\n\n this.logger.info('Message encoded successfully', {\n eventName,\n finalSize: finalMessage.length,\n messageId: header.messageId,\n });\n\n this.logger.trace('Encoded message hex dump', {\n hex: hexDump(finalMessage),\n });\n\n return finalMessage;\n } catch (error) {\n this.logger.error('Message encoding failed', {\n error: error instanceof Error ? error.message : String(error),\n eventName,\n eventDataLength: eventData.length,\n cause: error instanceof Error ? error.name : 'Unknown',\n });\n\n if (error instanceof MessageSizeError) {\n throw error;\n }\n throw new EncodingError('Failed to encode EventMsgV3 message', {\n context: { eventName, eventDataLength: eventData.length },\n cause: error instanceof Error ? error : new Error(String(error)),\n });\n }\n }\n\n /**\n * Encode a message with binary data into EventMsgV3 format\n *\n * @param header Message header (7 bytes)\n * @param eventName Event name string\n * @param eventData Binary event data\n * @returns Encoded binary message\n * @throws {EncodingError} If encoding fails\n * @throws {MessageSizeError} If message exceeds size limits\n */\n encodeBinary(\n header: MessageHeader,\n eventName: string,\n eventData: Uint8Array\n ): Uint8Array {\n this.logger.debug('Starting binary message encode', {\n eventName,\n eventDataLength: eventData.length,\n header,\n });\n\n try {\n // Validate inputs\n this.validateHeader(header);\n this.validateEventName(eventName);\n \n // Validate binary data size\n if (eventData.length > this.options.maxEventDataLength) {\n throw new MessageSizeError(\n eventData.length,\n this.options.maxEventDataLength,\n 'eventData'\n );\n }\n\n // Convert event name to bytes\n const eventNameBytes = this.stringToBytes(eventName);\n\n // Serialize header (7 bytes, big-endian messageId)\n const headerBytes = this.serializeHeader(header);\n\n // Apply byte stuffing to each section\n const stuffedHeader = stuff(headerBytes);\n const stuffedEventName = stuff(eventNameBytes);\n const stuffedEventData = stuff(eventData);\n\n this.logger.trace('Binary byte stuffing complete', {\n headerSize: `${headerBytes.length} -> ${stuffedHeader.length}`,\n nameSize: `${eventNameBytes.length} -> ${stuffedEventName.length}`,\n dataSize: `${eventData.length} -> ${stuffedEventData.length}`,\n });\n\n // Calculate total message size\n const totalSize =\n 1 +\n stuffedHeader.length +\n 1 +\n stuffedEventName.length +\n 1 +\n stuffedEventData.length +\n 1;\n\n if (totalSize > this.options.maxEventDataLength + 1000) {\n // Allow some overhead\n throw new MessageSizeError(\n totalSize,\n this.options.maxEventDataLength + 1000,\n 'encoded message'\n );\n }\n\n // Build final message: [SOH][StuffedHeader][STX][StuffedEventName][US][StuffedEventData][EOT]\n const message = new Uint8Array(totalSize);\n let offset = 0;\n\n // SOH\n message[offset++] = CONTROL_CHARS.SOH;\n\n // Stuffed Header\n message.set(stuffedHeader, offset);\n offset += stuffedHeader.length;\n\n // STX\n message[offset++] = CONTROL_CHARS.STX;\n\n // Stuffed Event Name\n message.set(stuffedEventName, offset);\n offset += stuffedEventName.length;\n\n // US\n message[offset++] = CONTROL_CHARS.US;\n\n // Stuffed Event Data (binary)\n message.set(stuffedEventData, offset);\n offset += stuffedEventData.length;\n\n // EOT\n message[offset++] = CONTROL_CHARS.EOT;\n\n const finalMessage = message.subarray(0, offset);\n\n this.logger.info('Binary message encoded successfully', {\n eventName,\n finalSize: finalMessage.length,\n messageId: header.messageId,\n });\n\n this.logger.trace('Encoded binary message hex dump', {\n hex: hexDump(finalMessage),\n });\n\n return finalMessage;\n } catch (error) {\n this.logger.error('Binary message encoding failed', {\n error: error instanceof Error ? error.message : String(error),\n eventName,\n eventDataLength: eventData.length,\n cause: error instanceof Error ? error.name : 'Unknown',\n });\n\n if (error instanceof MessageSizeError) {\n throw error;\n }\n throw new EncodingError('Failed to encode EventMsgV3 binary message', {\n context: { eventName, eventDataLength: eventData.length },\n cause: error instanceof Error ? error : new Error(String(error)),\n });\n }\n }\n\n /**\n * Decode a binary message into EventMsgV3 components\n *\n * @param data Binary message data\n * @returns Decoded message components\n * @throws {DecodingError} If decoding fails\n * @throws {InvalidMessageError} If message format is invalid\n */\n decode(data: Uint8Array): DecodedMessage {\n this.logger.debug('Starting message decode', {\n messageLength: data.length,\n encoding: this.encoding,\n });\n\n this.logger.trace('Incoming message hex dump', {\n hex: hexDump(data),\n });\n\n try {\n // Validate minimum message length (SOH + header + STX + name + US + data + EOT)\n if (data.length < 10) {\n throw new InvalidMessageError(\n `Message too short: ${data.length} bytes (minimum 10)`,\n { context: { messageLength: data.length } }\n );\n }\n\n // Validate framing characters\n if (data[0] !== CONTROL_CHARS.SOH) {\n throw new InvalidMessageError(\n `Message must start with SOH (0x01), got 0x${data[0]?.toString(16).padStart(2, '0')}`,\n { context: { firstByte: data[0] } }\n );\n }\n\n if (data.at(-1) !== CONTROL_CHARS.EOT) {\n throw new InvalidMessageError(\n `Message must end with EOT (0x04), got 0x${data.at(-1)?.toString(16).padStart(2, '0')}`,\n { context: { lastByte: data.at(-1) } }\n );\n }\n\n // Find section boundaries\n const stxIndex = this.findControlChar(data, CONTROL_CHARS.STX, 1);\n const usIndex = this.findControlChar(\n data,\n CONTROL_CHARS.US,\n stxIndex + 1\n );\n\n if (stxIndex === -1) {\n throw new InvalidMessageError('STX (0x02) not found in message');\n }\n if (usIndex === -1) {\n throw new InvalidMessageError('US (0x1F) not found in message');\n }\n\n // Extract and unstuff sections\n const stuffedHeader = data.subarray(1, stxIndex);\n const stuffedEventName = data.subarray(stxIndex + 1, usIndex);\n const stuffedEventData = data.subarray(usIndex + 1, data.length - 1);\n\n const headerBytes = unstuff(stuffedHeader);\n const eventNameBytes = unstuff(stuffedEventName);\n const eventDataBytes = unstuff(stuffedEventData);\n\n this.logger.trace('Byte unstuffing complete', {\n headerSize: `${stuffedHeader.length} -> ${headerBytes.length}`,\n nameSize: `${stuffedEventName.length} -> ${eventNameBytes.length}`,\n dataSize: `${stuffedEventData.length} -> ${eventDataBytes.length}`,\n });\n\n // Parse components\n const header = this.deserializeHeader(headerBytes);\n const eventName = this.bytesToString(eventNameBytes);\n const eventData = this.bytesToString(eventDataBytes);\n\n // Validate decoded components\n if (this.options.strictValidation) {\n this.validateHeader(header);\n this.validateEventName(eventName);\n this.validateEventData(eventData);\n }\n\n this.logger.info('Message decoded successfully', {\n eventName,\n messageId: header.messageId,\n senderId: header.senderId,\n receiverId: header.receiverId,\n eventDataLength: eventData.length,\n });\n\n return { header, eventName, eventData };\n } catch (error) {\n this.logger.error('Message decoding failed', {\n error: error instanceof Error ? error.message : String(error),\n messageLength: data.length,\n\n cause: error instanceof Error ? error.name : 'Unknown',\n hex: hexDump(data, 64),\n });\n\n if (error instanceof InvalidMessageError) {\n throw error;\n }\n throw new DecodingError('Failed to decode EventMsgV3 message', {\n context: { messageLength: data.length },\n cause: error instanceof Error ? error : new Error(String(error)),\n });\n }\n }\n\n /**\n * Find a control character in data, skipping escaped instances\n */\n private findControlChar(\n data: Uint8Array,\n target: number,\n startIndex: number\n ): number {\n let escaped = false;\n\n for (let i = startIndex; i < data.length; i++) {\n const byte = data[i]!;\n\n if (escaped) {\n escaped = false;\n } else if (byte === CONTROL_CHARS.ESC) {\n escaped = true;\n } else if (byte === target) {\n return i;\n }\n }\n\n return -1;\n }\n\n /**\n * Serialize message header to 7 bytes (big-endian messageId)\n */\n private serializeHeader(header: MessageHeader): Uint8Array {\n const bytes = new Uint8Array(7);\n bytes[0] = header.senderId;\n bytes[1] = header.receiverId;\n bytes[2] = header.senderGroupId;\n bytes[3] = header.receiverGroupId;\n bytes[4] = header.flags;\n bytes[5] = (header.messageId >> 8) & 0xff; // MSB\n bytes[6] = header.messageId & 0xff; // LSB\n return bytes;\n }\n\n /**\n * Deserialize 7 bytes to message header (big-endian messageId)\n */\n private deserializeHeader(bytes: Uint8Array): MessageHeader {\n if (bytes.length !== 7) {\n throw new InvalidMessageError(\n `Header must be exactly 7 bytes, got ${bytes.length}`,\n { context: { headerLength: bytes.length } }\n );\n }\n\n return {\n senderId: bytes[0]!,\n receiverId: bytes[1]!,\n senderGroupId: bytes[2]!,\n receiverGroupId: bytes[3]!,\n flags: bytes[4]!,\n messageId: (bytes[5]! << 8) | bytes[6]!, // Big-endian\n };\n }\n\n /**\n * Convert string to bytes using configured encoding\n */\n private stringToBytes(str: string): Uint8Array {\n if (this.encoding === 'utf8') {\n return new TextEncoder().encode(str);\n }\n // Binary encoding - each character is a byte\n const bytes = new Uint8Array(str.length);\n for (let i = 0; i < str.length; i++) {\n bytes[i] = str.charCodeAt(i) & 0xff;\n }\n return bytes;\n }\n\n /**\n * Convert bytes to string using configured encoding\n */\n private bytesToString(bytes: Uint8Array): string {\n if (this.encoding === 'utf8') {\n return new TextDecoder('utf8').decode(bytes);\n }\n // Binary encoding - each byte is a character\n return String.fromCharCode(...bytes);\n }\n\n /**\n * Validate message header fields\n */\n private validateHeader(header: MessageHeader): void {\n const fields = [\n { name: 'senderId', value: header.senderId },\n { name: 'receiverId', value: header.receiverId },\n { name: 'senderGroupId', value: header.senderGroupId },\n { name: 'receiverGroupId', value: header.receiverGroupId },\n { name: 'flags', value: header.flags },\n ];\n\n for (const field of fields) {\n if (\n field.value < 0 ||\n field.value > 255 ||\n !Number.isInteger(field.value)\n ) {\n throw new MessageSizeError(field.value, 255, field.name, {\n context: { validRange: '0-255' },\n });\n }\n }\n\n if (\n header.messageId < 0 ||\n header.messageId > 65_535 ||\n !Number.isInteger(header.messageId)\n ) {\n throw new MessageSizeError(header.messageId, 65_535, 'messageId', {\n context: { validRange: '0-65535' },\n });\n }\n }\n\n /**\n * Validate event name length\n */\n private validateEventName(eventName: string): void {\n const bytes = this.stringToBytes(eventName);\n if (bytes.length > this.options.maxEventNameLength) {\n throw new MessageSizeError(\n bytes.length,\n this.options.maxEventNameLength,\n 'eventName'\n );\n }\n }\n\n /**\n * Validate event data length\n */\n private validateEventData(eventData: string): void {\n const bytes = this.stringToBytes(eventData);\n if (bytes.length > this.options.maxEventDataLength) {\n throw new MessageSizeError(\n bytes.length,\n this.options.maxEventDataLength,\n 'eventData'\n );\n }\n }\n}\n","import { EventEmitter } from 'eventemitter3';\nimport {\n ConnectionError,\n DisconnectionError,\n SendError,\n ValidationError,\n} from '../errors/index.js';\nimport type { Transport, TransportConfig } from './transport.js';\n\n/**\n * Connection state for transport implementations\n */\ntype ConnectionState = 'disconnected' | 'connecting' | 'connected';\n\n/**\n * Abstract base class for transport implementations.\n *\n * Provides common functionality like connection state management, error handling,\n * and configuration validation while allowing transport-specific implementations\n * through the template method pattern.\n *\n * @template TConfig - Transport-specific configuration type extending TransportConfig\n */\nexport abstract class BaseTransport<\n TConfig extends TransportConfig = TransportConfig,\n >\n extends EventEmitter\n implements Transport\n{\n protected readonly config: TConfig;\n protected connectionState: ConnectionState = 'disconnected';\n private connectStartTime: Date | undefined;\n\n constructor(config: TConfig) {\n super();\n\n // Validate configuration\n this.validateConfig(config);\n this.config = { ...config };\n }\n\n // Abstract methods for transport-specific implementation\n\n /**\n * Transport-specific connection implementation.\n * Should establish the physical/logical connection.\n * @throws {Error} Transport-specific connection errors\n */\n protected abstract doConnect(): Promise<void>;\n\n /**\n * Transport-specific disconnection implementation.\n * Should clean up and close the connection.\n * @throws {Error} Transport-specific disconnection errors\n */\n protected abstract doDisconnect(): Promise<void>;\n\n /**\n * Transport-specific send implementation.\n * Should transmit the data through the transport medium.\n * @param data - Binary data to send\n * @throws {Error} Transport-specific send errors\n */\n protected abstract doSend(data: Uint8Array): Promise<void>;\n\n // Public interface implementation (Transport interface)\n\n /**\n * Connect to the transport layer.\n * Handles state management and error wrapping around doConnect().\n */\n async connect(): Promise<void> {\n // Validate state\n if (this.connectionState === 'connected') {\n return; // Already connected\n }\n\n if (this.connectionState === 'connecting') {\n throw new ConnectionError('Connection already in progress', {\n context: this.getErrorContext('connect'),\n });\n }\n\n this.connectionState = 'connecting';\n this.connectStartTime = new Date();\n\n try {\n await this.doConnect();\n this.connectionState = 'connected';\n this.emit('connect');\n } catch (error) {\n this.connectionState = 'disconnected';\n this.connectStartTime = undefined;\n\n throw new ConnectionError(\n `Failed to connect via ${this.constructor.name}`,\n {\n cause: error instanceof Error ? error : new Error(String(error)),\n context: this.getErrorContext('connect'),\n }\n );\n }\n }\n\n /**\n * Disconnect from the transport layer.\n * Handles state management and error wrapping around doDisconnect().\n */\n async disconnect(): Promise<void> {\n if (this.connectionState === 'disconnected') {\n return; // Already disconnected\n }\n\n const wasConnected = this.connectionState === 'connected';\n\n try {\n await this.doDisconnect();\n } catch (error) {\n throw new DisconnectionError(\n `Failed to disconnect from ${this.constructor.name}`,\n {\n cause: error instanceof Error ? error : new Error(String(error)),\n context: this.getErrorContext('disconnect'),\n }\n );\n } finally {\n this.connectionState = 'disconnected';\n this.connectStartTime = undefined;\n\n if (wasConnected) {\n this.emit('disconnect');\n }\n }\n }\n\n /**\n * Check if transport is currently connected.\n */\n isConnected(): boolean {\n return this.connectionState === 'connected';\n }\n\n /**\n * Send raw binary data through the transport.\n * Validates state and data before calling doSend().\n */\n async send(data: Uint8Array): Promise<void> {\n // Validate connection state\n if (this.connectionState !== 'connected') {\n throw new SendError('Cannot send data: transport not connected', {\n context: this.getErrorContext('send', { dataSize: data.length }),\n });\n }\n\n // Validate data\n if (!(data instanceof Uint8Array)) {\n throw new ValidationError('Data must be a Uint8Array', {\n context: this.getErrorContext('send', { dataType: typeof data }),\n });\n }\n\n if (data.length === 0) {\n throw new ValidationError('Cannot send empty data', {\n context: this.getErrorContext('send', { dataSize: 0 }),\n });\n }\n\n try {\n await this.doSend(data);\n } catch (error) {\n throw new SendError(\n `Failed to send ${data.length} bytes via ${this.constructor.name}`,\n {\n cause: error instanceof Error ? error : new Error(String(error)),\n context: this.getErrorContext('send', { dataSize: data.length }),\n }\n );\n }\n }\n\n /**\n * Get the local device address.\n */\n getLocalAddress(): number {\n return this.config.localAddress;\n }\n\n /**\n * Get the local group address.\n */\n getGroupAddress(): number {\n return this.config.groupAddress;\n }\n\n // Protected helper methods\n\n /**\n * Validate transport configuration.\n * @param config - Configuration to validate\n * @throws {ValidationError} If configuration is invalid\n */\n protected validateConfig(config: TConfig): void {\n if (!config) {\n throw new ValidationError('Transport configuration is required');\n }\n\n // Validate localAddress\n if (typeof config.localAddress !== 'number') {\n throw new ValidationError('localAddress must be a number', {\n context: { localAddress: config.localAddress },\n });\n }\n\n if (\n !Number.isInteger(config.localAddress) ||\n config.localAddress < 0 ||\n config.localAddress > 255\n ) {\n throw new ValidationError(\n 'localAddress must be an integer between 0-255',\n {\n context: { localAddress: config.localAddress },\n }\n );\n }\n\n // Validate groupAddress\n if (typeof config.groupAddress !== 'number') {\n throw new ValidationError('groupAddress must be a number', {\n context: { groupAddress: config.groupAddress },\n });\n }\n\n if (\n !Number.isInteger(config.groupAddress) ||\n config.groupAddress < 0 ||\n config.groupAddress > 255\n ) {\n throw new ValidationError(\n 'groupAddress must be an integer between 0-255',\n {\n context: { groupAddress: config.groupAddress },\n }\n );\n }\n }\n\n /**\n * Generate error context for consistent error reporting.\n * @param operation - Operation being performed\n * @param additional - Additional context data\n * @returns Error context object\n */\n protected getErrorContext(\n operation: string,\n additional: Record<string, unknown> = {}\n ): Record<string, unknown> {\n return {\n transportType: this.constructor.name,\n state: this.connectionState,\n operation,\n localAddress: this.config.localAddress,\n groupAddress: this.config.groupAddress,\n timestamp: new Date().toISOString(),\n connectedSince: this.connectStartTime?.toISOString(),\n ...additional,\n };\n }\n\n /**\n * Emit a transport error event.\n * @param error - Error to emit\n */\n protected emitError(error: Error): void {\n this.emit('error', error);\n }\n\n /**\n * Handle unexpected disconnection.\n * Should be called by transport implementations when connection is lost.\n */\n protected handleUnexpectedDisconnect(reason?: Error): void {\n const wasConnected = this.connectionState === 'connected';\n this.connectionState = 'disconnected';\n this.connectStartTime = undefined;\n\n if (wasConnected) {\n this.emit('disconnect');\n\n if (reason) {\n this.emitError(\n new DisconnectionError('Transport disconnected unexpectedly', {\n cause: reason,\n context: this.getErrorContext('unexpected_disconnect'),\n })\n );\n }\n }\n }\n}\n","import type { MessageMetadata } from './message.js';\n\n/**\n * Event handler function type for type-s