feat(protocol): add codec module with encode/decode and envelope builders

- Add encodeBuiltin/decodeBuiltin for builtin message wire format
- Add parseRuleMessage/parseRewrittenRuleMessage for rule dispatch
- Add type-safe envelope builders for all builtin message types
- Export codec module from index.ts
This commit is contained in:
nav
2026-04-08 20:20:11 +00:00
parent de9c41fc88
commit fb4cd6e45b
2 changed files with 361 additions and 0 deletions

360
src/codec.ts Normal file
View File

@@ -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<TType extends BuiltinMessageType>(
envelope: BuiltinEnvelope<TType, BuiltinPayloadMap[TType]>
): 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<string, unknown>;
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<TType extends keyof BuiltinPayloadMap>(
type: TType
) {
return (
payload: BuiltinPayloadMap[TType],
options?: { requestId?: string; timestamp?: number }
): BuiltinEnvelope<TType, BuiltinPayloadMap[TType]> => {
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");

View File

@@ -1 +1,2 @@
export * from "./types.js"; export * from "./types.js";
export * from "./codec.js";