Compare commits
2 Commits
9232aa7c17
...
fb4cd6e45b
| Author | SHA1 | Date | |
|---|---|---|---|
| fb4cd6e45b | |||
|
|
de9c41fc88 |
@@ -7,7 +7,8 @@ It is referenced as a git submodule by both `Yonexus.Server` and `Yonexus.Client
|
||||
## Contents
|
||||
|
||||
- `PROTOCOL.md` — full protocol specification
|
||||
- TypeScript type definitions (planned: `src/types.ts`)
|
||||
- `src/types.ts` — shared builtin envelope / payload TypeScript definitions
|
||||
- `src/index.ts` — package export surface
|
||||
- Canonical JSON shape references
|
||||
|
||||
## Purpose
|
||||
|
||||
360
src/codec.ts
Normal file
360
src/codec.ts
Normal 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");
|
||||
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types.js";
|
||||
export * from "./codec.js";
|
||||
182
src/types.ts
Normal file
182
src/types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
export const YONEXUS_PROTOCOL_VERSION = "1" as const;
|
||||
|
||||
export const builtinMessageTypes = [
|
||||
"hello",
|
||||
"hello_ack",
|
||||
"pair_request",
|
||||
"pair_confirm",
|
||||
"pair_success",
|
||||
"pair_failed",
|
||||
"auth_request",
|
||||
"auth_success",
|
||||
"auth_failed",
|
||||
"re_pair_required",
|
||||
"heartbeat",
|
||||
"heartbeat_ack",
|
||||
"status_update",
|
||||
"disconnect_notice",
|
||||
"error"
|
||||
] as const;
|
||||
|
||||
export type BuiltinMessageType = (typeof builtinMessageTypes)[number];
|
||||
|
||||
export interface BuiltinEnvelope<
|
||||
TType extends BuiltinMessageType = BuiltinMessageType,
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>
|
||||
> {
|
||||
type: TType;
|
||||
requestId?: string;
|
||||
timestamp?: number;
|
||||
payload?: TPayload;
|
||||
}
|
||||
|
||||
export interface HelloPayload {
|
||||
identifier: string;
|
||||
hasSecret: boolean;
|
||||
hasKeyPair: boolean;
|
||||
publicKey?: string;
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export type HelloAckNextAction =
|
||||
| "pair_required"
|
||||
| "auth_required"
|
||||
| "rejected"
|
||||
| "waiting_pair_confirm";
|
||||
|
||||
export interface HelloAckPayload {
|
||||
identifier: string;
|
||||
nextAction: HelloAckNextAction;
|
||||
}
|
||||
|
||||
export type AdminNotificationStatus = "sent" | "failed";
|
||||
|
||||
export interface PairRequestPayload {
|
||||
identifier: string;
|
||||
expiresAt: number;
|
||||
ttlSeconds: number;
|
||||
adminNotification: AdminNotificationStatus;
|
||||
codeDelivery: "out_of_band";
|
||||
}
|
||||
|
||||
export interface PairConfirmPayload {
|
||||
identifier: string;
|
||||
pairingCode: string;
|
||||
}
|
||||
|
||||
export interface PairSuccessPayload {
|
||||
identifier: string;
|
||||
secret: string;
|
||||
pairedAt: number;
|
||||
}
|
||||
|
||||
export type PairFailedReason =
|
||||
| "expired"
|
||||
| "invalid_code"
|
||||
| "identifier_not_allowed"
|
||||
| "admin_notification_failed"
|
||||
| "internal_error";
|
||||
|
||||
export interface PairFailedPayload {
|
||||
identifier: string;
|
||||
reason: PairFailedReason;
|
||||
}
|
||||
|
||||
export interface AuthRequestPayload {
|
||||
identifier: string;
|
||||
nonce: string;
|
||||
proofTimestamp: number;
|
||||
signature: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
export interface AuthSuccessPayload {
|
||||
identifier: string;
|
||||
authenticatedAt: number;
|
||||
status: "online";
|
||||
}
|
||||
|
||||
export type AuthFailedReason =
|
||||
| "unknown_identifier"
|
||||
| "not_paired"
|
||||
| "invalid_signature"
|
||||
| "invalid_secret"
|
||||
| "stale_timestamp"
|
||||
| "future_timestamp"
|
||||
| "nonce_collision"
|
||||
| "rate_limited"
|
||||
| "re_pair_required";
|
||||
|
||||
export interface AuthFailedPayload {
|
||||
identifier: string;
|
||||
reason: AuthFailedReason;
|
||||
}
|
||||
|
||||
export interface RePairRequiredPayload {
|
||||
identifier: string;
|
||||
reason: "nonce_collision" | "rate_limited" | "trust_revoked";
|
||||
}
|
||||
|
||||
export interface HeartbeatPayload {
|
||||
identifier: string;
|
||||
status: "alive";
|
||||
}
|
||||
|
||||
export interface HeartbeatAckPayload {
|
||||
identifier: string;
|
||||
status: "online" | "unstable" | "offline";
|
||||
}
|
||||
|
||||
export interface StatusUpdatePayload {
|
||||
identifier: string;
|
||||
status: "online" | "unstable" | "offline";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DisconnectNoticePayload {
|
||||
identifier: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type ProtocolErrorCode =
|
||||
| "MALFORMED_MESSAGE"
|
||||
| "UNSUPPORTED_PROTOCOL_VERSION"
|
||||
| "IDENTIFIER_NOT_ALLOWED"
|
||||
| "PAIRING_REQUIRED"
|
||||
| "PAIRING_EXPIRED"
|
||||
| "ADMIN_NOTIFICATION_FAILED"
|
||||
| "AUTH_FAILED"
|
||||
| "NONCE_COLLISION"
|
||||
| "RATE_LIMITED"
|
||||
| "RE_PAIR_REQUIRED"
|
||||
| "CLIENT_OFFLINE"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface ErrorPayload {
|
||||
code: ProtocolErrorCode;
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type BuiltinPayloadMap = {
|
||||
hello: HelloPayload;
|
||||
hello_ack: HelloAckPayload;
|
||||
pair_request: PairRequestPayload;
|
||||
pair_confirm: PairConfirmPayload;
|
||||
pair_success: PairSuccessPayload;
|
||||
pair_failed: PairFailedPayload;
|
||||
auth_request: AuthRequestPayload;
|
||||
auth_success: AuthSuccessPayload;
|
||||
auth_failed: AuthFailedPayload;
|
||||
re_pair_required: RePairRequiredPayload;
|
||||
heartbeat: HeartbeatPayload;
|
||||
heartbeat_ack: HeartbeatAckPayload;
|
||||
status_update: StatusUpdatePayload;
|
||||
disconnect_notice: DisconnectNoticePayload;
|
||||
error: ErrorPayload;
|
||||
};
|
||||
|
||||
export type TypedBuiltinEnvelope<TType extends keyof BuiltinPayloadMap> = BuiltinEnvelope<
|
||||
TType,
|
||||
BuiltinPayloadMap[TType]
|
||||
>;
|
||||
Reference in New Issue
Block a user