diff --git a/src/codec.ts b/src/codec.ts new file mode 100644 index 0000000..3bfa8a2 --- /dev/null +++ b/src/codec.ts @@ -0,0 +1,360 @@ +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 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"); diff --git a/src/index.ts b/src/index.ts index a0c4ebf..8212ccf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./types.js"; +export * from "./codec.js";