Add client runtime and hello handshake
This commit is contained in:
53
package-lock.json
generated
Normal file
53
package-lock.json
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"check": "tsc -p tsconfig.json --noEmit"
|
"check": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,27 +29,27 @@ function isValidWsUrl(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateYonexusClientConfig(raw: unknown): YonexusClientConfig {
|
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 issues: string[] = [];
|
||||||
|
|
||||||
const mainHost = source?.mainHost;
|
const mainHost = source.mainHost;
|
||||||
if (!isNonEmptyString(mainHost)) {
|
if (!isNonEmptyString(mainHost)) {
|
||||||
issues.push("mainHost is required");
|
issues.push("mainHost is required");
|
||||||
} else if (!isValidWsUrl(mainHost.trim())) {
|
} else if (!isValidWsUrl(mainHost.trim())) {
|
||||||
issues.push("mainHost must be a valid ws:// or wss:// URL");
|
issues.push("mainHost must be a valid ws:// or wss:// URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifier = source?.identifier;
|
const identifier = source.identifier;
|
||||||
if (!isNonEmptyString(identifier)) {
|
if (!isNonEmptyString(identifier)) {
|
||||||
issues.push("identifier is required");
|
issues.push("identifier is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyBotToken = source?.notifyBotToken;
|
const notifyBotToken = source.notifyBotToken;
|
||||||
if (!isNonEmptyString(notifyBotToken)) {
|
if (!isNonEmptyString(notifyBotToken)) {
|
||||||
issues.push("notifyBotToken is required");
|
issues.push("notifyBotToken is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUserId = source?.adminUserId;
|
const adminUserId = source.adminUserId;
|
||||||
if (!isNonEmptyString(adminUserId)) {
|
if (!isNonEmptyString(adminUserId)) {
|
||||||
issues.push("adminUserId is required");
|
issues.push("adminUserId is required");
|
||||||
}
|
}
|
||||||
|
|||||||
158
plugin/core/runtime.ts
Normal file
158
plugin/core/runtime.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
:
|
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import type { YonexusClientConfig } from "./config.js";
|
import type { YonexusClientConfig } from "./config.js";
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export {
|
|||||||
type ClientStateChangeHandler,
|
type ClientStateChangeHandler,
|
||||||
type ClientErrorHandler
|
type ClientErrorHandler
|
||||||
} from "./core/transport.js";
|
} from "./core/transport.js";
|
||||||
|
export {
|
||||||
|
createYonexusClientRuntime,
|
||||||
|
YonexusClientRuntime,
|
||||||
|
type YonexusClientRuntimeOptions,
|
||||||
|
type YonexusClientRuntimeState,
|
||||||
|
type YonexusClientPhase
|
||||||
|
} from "./core/runtime.js";
|
||||||
|
|
||||||
export interface YonexusClientPluginManifest {
|
export interface YonexusClientPluginManifest {
|
||||||
readonly name: "Yonexus.Client";
|
readonly name: "Yonexus.Client";
|
||||||
|
|||||||
Reference in New Issue
Block a user