Add client runtime and hello handshake

This commit is contained in:
nav
2026-04-08 21:13:16 +00:00
parent bc3e931979
commit fb39a17dbb
6 changed files with 226 additions and 6 deletions

View File

@@ -29,27 +29,27 @@ function isValidWsUrl(value: string): boolean {
}
export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig {
const source = raw as Record<string, unknown> | null;
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const issues: string[] = [];
const mainHost = source?.mainHost;
const mainHost = source.mainHost;
if (!isNonEmptyString(mainHost)) {
issues.push("mainHost is required");
} else if (!isValidWsUrl(mainHost.trim())) {
issues.push("mainHost must be a valid ws:// or wss:// URL");
}
const identifier = source?.identifier;
const identifier = source.identifier;
if (!isNonEmptyString(identifier)) {
issues.push("identifier is required");
}
const notifyBotToken = source?.notifyBotToken;
const notifyBotToken = source.notifyBotToken;
if (!isNonEmptyString(notifyBotToken)) {
issues.push("notifyBotToken is required");
}
const adminUserId = source?.adminUserId;
const adminUserId = source.adminUserId;
if (!isNonEmptyString(adminUserId)) {
issues.push("adminUserId is required");
}

158
plugin/core/runtime.ts Normal file
View File

@@ -0,0 +1,158 @@
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<void> {
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<void> {
await this.options.stateStore.save({
...this.clientState,
updatedAt: this.now()
});
this.options.transport.disconnect();
this.phase = "stopped";
}
async handleMessage(raw: string): Promise<void> {
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);
}

View File

@@ -1,4 +1,3 @@
:
import { WebSocket } from "ws";
import type { YonexusClientConfig } from "./config.js";