From fb39a17dbbef31d49604de44b09bf02bc13b6701 Mon Sep 17 00:00:00 2001 From: nav Date: Wed, 8 Apr 2026 21:13:16 +0000 Subject: [PATCH] Add client runtime and hello handshake --- package-lock.json | 53 +++++++++++++ package.json | 3 + plugin/core/config.ts | 10 +-- plugin/core/runtime.ts | 158 +++++++++++++++++++++++++++++++++++++++ plugin/core/transport.ts | 1 - plugin/index.ts | 7 ++ 6 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 package-lock.json create mode 100644 plugin/core/runtime.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..528f28b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "yonexus-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yonexus-client", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "typescript": "^5.6.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 1a0dd5a..a42e03f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "clean": "rm -rf dist", "check": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "ws": "^8.18.0" + }, "devDependencies": { "typescript": "^5.6.3" } diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 1d2e9b0..421ef5b 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -29,27 +29,27 @@ function isValidWsUrl(value: string): boolean { } export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig { - const source = raw as Record | null; + const source = (raw && typeof raw === "object" ? raw : {}) as Record; 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"); } diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts new file mode 100644 index 0000000..9790091 --- /dev/null +++ b/plugin/core/runtime.ts @@ -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 { + 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); +} diff --git a/plugin/core/transport.ts b/plugin/core/transport.ts index f729e64..f227d71 100644 --- a/plugin/core/transport.ts +++ b/plugin/core/transport.ts @@ -1,4 +1,3 @@ -: import { WebSocket } from "ws"; import type { YonexusClientConfig } from "./config.js"; diff --git a/plugin/index.ts b/plugin/index.ts index 266a71d..9b7857f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -24,6 +24,13 @@ export { type ClientStateChangeHandler, type ClientErrorHandler } from "./core/transport.js"; +export { + createYonexusClientRuntime, + YonexusClientRuntime, + type YonexusClientRuntimeOptions, + type YonexusClientRuntimeState, + type YonexusClientPhase +} from "./core/runtime.js"; export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client";