import { type BuiltinEnvelope, type BuiltinMessageType, type BuiltinPayloadMap } from "./types.js"; /** * Yonexus Protocol Codec * * Provides unified string protocol parsing and serialization. * * Wire formats: * - builtin::{json} for protocol messages * - ${rule_identifier}::${message_content} for application messages */ // Reserved rule identifier for builtin messages export const BUILTIN_RULE = "builtin" as const; // Delimiter for rule messages const RULE_DELIMITER = "::"; /** * Errors that can occur during encoding/decoding */ export class CodecError extends Error { constructor(message: string) { super(message); this.name = "CodecError"; } } /** * Result of parsing a rule message */ export interface ParsedRuleMessage { /** The rule identifier (first segment before ::) */ readonly ruleIdentifier: string; /** The raw content after the first :: delimiter */ readonly content: string; } /** * Result of parsing a server-rewritten rule message */ export interface ParsedRewrittenRuleMessage extends ParsedRuleMessage { /** The sender identifier (second segment) */ readonly senderIdentifier: string; /** The actual message content (after sender identifier) */ readonly messageContent: string; } /** * Encode a builtin envelope to wire format. * * @param envelope - The builtin envelope to encode * @returns Wire format string: builtin::{json} * @throws CodecError if envelope is invalid or JSON serialization fails */ export function encodeBuiltin( envelope: BuiltinEnvelope ): string { if (!envelope || typeof envelope !== "object") { throw new CodecError("Envelope must be an object"); } if (!envelope.type || typeof envelope.type !== "string") { throw new CodecError("Envelope must have a valid 'type' field"); } // Build the envelope with defaults const fullEnvelope: BuiltinEnvelope = { timestamp: Math.floor(Date.now() / 1000), ...envelope }; try { const json = JSON.stringify(fullEnvelope); return `${BUILTIN_RULE}${RULE_DELIMITER}${json}`; } catch (cause) { throw new CodecError( `Failed to serialize envelope: ${cause instanceof Error ? cause.message : String(cause)}` ); } } /** * Decode a builtin message from wire format. * * @param raw - The raw wire message (expected: builtin::{json}) * @returns Parsed builtin envelope * @throws CodecError if format is invalid or JSON parsing fails */ export function decodeBuiltin(raw: string): BuiltinEnvelope { if (typeof raw !== "string") { throw new CodecError("Input must be a string"); } // Find the first :: delimiter const delimiterIndex = raw.indexOf(RULE_DELIMITER); if (delimiterIndex === -1) { throw new CodecError("Malformed message: missing :: delimiter"); } const ruleIdentifier = raw.slice(0, delimiterIndex); const content = raw.slice(delimiterIndex + RULE_DELIMITER.length); if (ruleIdentifier !== BUILTIN_RULE) { throw new CodecError( `Expected rule identifier '${BUILTIN_RULE}', got '${ruleIdentifier}'` ); } if (content.length === 0) { throw new CodecError("Empty builtin payload"); } try { const parsed = JSON.parse(content) as unknown; if (!parsed || typeof parsed !== "object") { throw new CodecError("Builtin payload must be a JSON object"); } const envelope = parsed as Record; if (!envelope.type || typeof envelope.type !== "string") { throw new CodecError("Missing or invalid 'type' field in envelope"); } return envelope as unknown as BuiltinEnvelope; } catch (cause) { if (cause instanceof CodecError) { throw cause; } throw new CodecError( `Failed to parse JSON payload: ${cause instanceof Error ? cause.message : String(cause)}` ); } } /** * Parse a rule message from wire format. * * Only splits on the first :: delimiter, preserving any :: in the content. * * @param raw - The raw wire message * @returns Parsed rule message with identifier and content * @throws CodecError if format is invalid */ export function parseRuleMessage(raw: string): ParsedRuleMessage { if (typeof raw !== "string") { throw new CodecError("Input must be a string"); } const delimiterIndex = raw.indexOf(RULE_DELIMITER); if (delimiterIndex === -1) { throw new CodecError( `Malformed rule message: missing '${RULE_DELIMITER}' delimiter` ); } const ruleIdentifier = raw.slice(0, delimiterIndex); const content = raw.slice(delimiterIndex + RULE_DELIMITER.length); if (ruleIdentifier.length === 0) { throw new CodecError("Empty rule identifier"); } // Validate rule identifier doesn't contain spaces or control characters if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) { throw new CodecError( `Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only` ); } // Check for reserved builtin identifier if (ruleIdentifier === BUILTIN_RULE) { throw new CodecError( `Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages` ); } return { ruleIdentifier, content }; } /** * Parse a server-rewritten rule message. * * Server rewrites: ${rule}::${content} -> ${rule}::${sender}::${content} * * @param raw - The raw wire message from server * @returns Parsed rewritten message with sender identifier * @throws CodecError if format is invalid */ export function parseRewrittenRuleMessage(raw: string): ParsedRewrittenRuleMessage { if (typeof raw !== "string") { throw new CodecError("Input must be a string"); } // Find first delimiter const firstDelimiterIndex = raw.indexOf(RULE_DELIMITER); if (firstDelimiterIndex === -1) { throw new CodecError( `Malformed rule message: missing '${RULE_DELIMITER}' delimiter` ); } const ruleIdentifier = raw.slice(0, firstDelimiterIndex); const afterRule = raw.slice(firstDelimiterIndex + RULE_DELIMITER.length); if (ruleIdentifier.length === 0) { throw new CodecError("Empty rule identifier"); } // Find second delimiter const secondDelimiterIndex = afterRule.indexOf(RULE_DELIMITER); if (secondDelimiterIndex === -1) { throw new CodecError( "Malformed rewritten message: missing sender identifier delimiter" ); } const senderIdentifier = afterRule.slice(0, secondDelimiterIndex); const messageContent = afterRule.slice(secondDelimiterIndex + RULE_DELIMITER.length); if (senderIdentifier.length === 0) { throw new CodecError("Empty sender identifier"); } return { ruleIdentifier, senderIdentifier, content: afterRule, // raw content after rule (includes sender::message) messageContent }; } /** * Encode a rule message to wire format. * * @param ruleIdentifier - The rule identifier * @param content - The message content * @returns Wire format string: ${rule_identifier}::${message_content} * @throws CodecError if ruleIdentifier is invalid or reserved */ export function encodeRuleMessage( ruleIdentifier: string, content: string ): string { if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) { throw new CodecError("Rule identifier must be a non-empty string"); } if (typeof content !== "string") { throw new CodecError("Content must be a string"); } // Validate rule identifier format if (!/^[a-zA-Z0-9_-]+$/.test(ruleIdentifier)) { throw new CodecError( `Invalid rule identifier '${ruleIdentifier}': must be alphanumeric with underscores/hyphens only` ); } if (ruleIdentifier === BUILTIN_RULE) { throw new CodecError( `Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages` ); } return `${ruleIdentifier}${RULE_DELIMITER}${content}`; } /** * Encode a server-rewritten rule message. * * @param ruleIdentifier - The rule identifier * @param senderIdentifier - The client identifier that sent the message * @param content - The original message content * @returns Wire format string: ${rule}::${sender}::${content} * @throws CodecError if identifiers are invalid */ export function encodeRewrittenRuleMessage( ruleIdentifier: string, senderIdentifier: string, content: string ): string { if (typeof ruleIdentifier !== "string" || ruleIdentifier.length === 0) { throw new CodecError("Rule identifier must be a non-empty string"); } if (typeof senderIdentifier !== "string" || senderIdentifier.length === 0) { throw new CodecError("Sender identifier must be a non-empty string"); } if (typeof content !== "string") { throw new CodecError("Content must be a string"); } if (ruleIdentifier === BUILTIN_RULE) { throw new CodecError( `Rule identifier '${BUILTIN_RULE}' is reserved for protocol messages` ); } return `${ruleIdentifier}${RULE_DELIMITER}${senderIdentifier}${RULE_DELIMITER}${content}`; } /** * Check if a raw message is a builtin message. * * @param raw - The raw wire message * @returns True if the message starts with "builtin::" */ export function isBuiltinMessage(raw: string): boolean { return typeof raw === "string" && raw.startsWith(`${BUILTIN_RULE}${RULE_DELIMITER}`); } /** * Create a type-safe builtin envelope builder. * * @param type - The builtin message type * @returns A builder function that creates envelopes of that type */ export function createEnvelopeBuilder( type: TType ) { return ( payload: BuiltinPayloadMap[TType], options?: { requestId?: string; timestamp?: number } ): BuiltinEnvelope => { return { type, requestId: options?.requestId, timestamp: options?.timestamp ?? Math.floor(Date.now() / 1000), payload }; }; } // Pre-built envelope builders for common message types export const buildHello = createEnvelopeBuilder("hello"); export const buildHelloAck = createEnvelopeBuilder("hello_ack"); export const buildPairRequest = createEnvelopeBuilder("pair_request"); export const buildPairConfirm = createEnvelopeBuilder("pair_confirm"); export const buildPairSuccess = createEnvelopeBuilder("pair_success"); export const buildPairFailed = createEnvelopeBuilder("pair_failed"); export const buildAuthRequest = createEnvelopeBuilder("auth_request"); export const buildAuthSuccess = createEnvelopeBuilder("auth_success"); export const buildAuthFailed = createEnvelopeBuilder("auth_failed"); export const buildRePairRequired = createEnvelopeBuilder("re_pair_required"); export const buildHeartbeat = createEnvelopeBuilder("heartbeat"); export const buildHeartbeatAck = createEnvelopeBuilder("heartbeat_ack"); export const buildStatusUpdate = createEnvelopeBuilder("status_update"); export const buildDisconnectNotice = createEnvelopeBuilder("disconnect_notice"); export const buildError = createEnvelopeBuilder("error");