import { YONEXUS_PROTOCOL_VERSION, buildHello, decodeBuiltin, encodeBuiltin, isBuiltinMessage, type HelloAckPayload, type TypedBuiltinEnvelope } from "../../../Yonexus.Protocol/src/index.js"; import type { YonexusClientConfig } from "./config.js"; import { createInitialClientState, hasClientKeyPair, hasClientSecret, type YonexusClientState, type YonexusClientStateStore } from "./state.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; export type YonexusClientPhase = | "idle" | "starting" | "awaiting_hello_ack" | "pair_required" | "waiting_pair_confirm" | "auth_required" | "authenticated" | "stopped"; export interface YonexusClientRuntimeOptions { config: YonexusClientConfig; transport: ClientTransport; stateStore: YonexusClientStateStore; now?: () => number; } export interface YonexusClientRuntimeState { readonly phase: YonexusClientPhase; readonly transportState: ClientConnectionState; readonly clientState: YonexusClientState; } export class YonexusClientRuntime { private readonly options: YonexusClientRuntimeOptions; private readonly now: () => number; private clientState: YonexusClientState; private phase: YonexusClientPhase = "idle"; constructor(options: YonexusClientRuntimeOptions) { this.options = options; this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); this.clientState = createInitialClientState(options.config.identifier); } get state(): YonexusClientRuntimeState { return { phase: this.phase, transportState: this.options.transport.state, clientState: this.clientState }; } async start(): Promise { if (this.phase !== "idle" && this.phase !== "stopped") { return; } this.phase = "starting"; this.clientState = await this.options.stateStore.load(this.options.config.identifier); await this.options.transport.connect(); } async stop(): Promise { await this.options.stateStore.save({ ...this.clientState, updatedAt: this.now() }); this.options.transport.disconnect(); this.phase = "stopped"; } async handleMessage(raw: string): Promise { if (raw === "heartbeat_tick") { return; } if (!isBuiltinMessage(raw)) { return; } const envelope = decodeBuiltin(raw); if (envelope.type === "hello_ack") { this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); return; } if (envelope.type === "auth_success") { this.phase = "authenticated"; return; } } handleTransportStateChange(state: ClientConnectionState): void { if (state === "connected") { this.sendHello(); } if (state === "disconnected") { this.phase = "idle"; } } private sendHello(): void { this.phase = "awaiting_hello_ack"; this.options.transport.send( encodeBuiltin( buildHello( { identifier: this.options.config.identifier, hasSecret: hasClientSecret(this.clientState), hasKeyPair: hasClientKeyPair(this.clientState), publicKey: this.clientState.publicKey, protocolVersion: YONEXUS_PROTOCOL_VERSION }, { timestamp: this.now() } ) ) ); } private handleHelloAck(envelope: TypedBuiltinEnvelope<"hello_ack">): void { const payload = envelope.payload as HelloAckPayload | undefined; if (!payload) { return; } switch (payload.nextAction) { case "pair_required": this.phase = "pair_required"; break; case "waiting_pair_confirm": this.phase = "waiting_pair_confirm"; break; case "auth_required": this.phase = "auth_required"; break; default: this.phase = "idle"; break; } } } export function createYonexusClientRuntime( options: YonexusClientRuntimeOptions ): YonexusClientRuntime { return new YonexusClientRuntime(options); }