Compare commits

...

1 Commits

Author SHA1 Message Date
nav
ddeed9a7b7 Harden client reconnect and protocol guards 2026-04-08 23:03:54 +00:00
2 changed files with 81 additions and 9 deletions

View File

@@ -4,11 +4,14 @@ import {
buildHeartbeat, buildHeartbeat,
buildHello, buildHello,
buildPairConfirm, buildPairConfirm,
CodecError,
createAuthRequestSigningInput, createAuthRequestSigningInput,
decodeBuiltin, decodeBuiltin,
encodeBuiltin, encodeBuiltin,
encodeRuleMessage,
isBuiltinMessage, isBuiltinMessage,
type AuthFailedPayload, type AuthFailedPayload,
type BuiltinPayloadMap,
type HelloAckPayload, type HelloAckPayload,
type PairFailedPayload, type PairFailedPayload,
type PairRequestPayload, type PairRequestPayload,
@@ -118,7 +121,15 @@ export class YonexusClientRuntime {
return; return;
} }
const envelope = decodeBuiltin(raw); let envelope: TypedBuiltinEnvelope<keyof BuiltinPayloadMap>;
try {
envelope = decodeBuiltin(raw) as TypedBuiltinEnvelope<keyof BuiltinPayloadMap>;
} catch (error) {
if (error instanceof CodecError) {
this.lastPairingFailure = error.message;
}
return;
}
if (envelope.type === "hello_ack") { if (envelope.type === "hello_ack") {
this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">); this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">);
return; return;
@@ -160,6 +171,8 @@ export class YonexusClientRuntime {
await this.handleRePairRequired(); await this.handleRePairRequired();
return; return;
} }
this.lastPairingFailure = `unsupported_builtin:${String(envelope.type)}`;
} }
handleTransportStateChange(state: ClientConnectionState): void { handleTransportStateChange(state: ClientConnectionState): void {
@@ -169,6 +182,7 @@ export class YonexusClientRuntime {
if (state === "disconnected") { if (state === "disconnected") {
this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle"; this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle";
this.pendingPairing = undefined;
} }
} }
@@ -291,7 +305,11 @@ export class YonexusClientRuntime {
return; return;
} }
if (payload.reason === "re_pair_required") { if (
payload.reason === "re_pair_required" ||
payload.reason === "nonce_collision" ||
payload.reason === "rate_limited"
) {
this.lastPairingFailure = payload.reason; this.lastPairingFailure = payload.reason;
await this.resetTrustState(); await this.resetTrustState();
return; return;
@@ -375,6 +393,57 @@ export class YonexusClientRuntime {
await this.options.stateStore.save(this.clientState); await this.options.stateStore.save(this.clientState);
this.phase = "pair_required"; this.phase = "pair_required";
} }
/**
* Send a rule message to the server.
* Message must already conform to `${rule_identifier}::${message_content}`.
*
* @param message - The complete rule message with identifier and content
* @returns True if message was sent, false if not connected or not authenticated
*/
sendMessageToServer(message: string): boolean {
if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) {
return false;
}
// Validate the message is a properly formatted rule message
try {
if (message.startsWith("builtin::")) {
return false;
}
const delimiterIndex = message.indexOf("::");
if (delimiterIndex === -1) {
return false;
}
const ruleIdentifier = message.slice(0, delimiterIndex);
const content = message.slice(delimiterIndex + 2);
encodeRuleMessage(ruleIdentifier, content);
} catch {
return false;
}
return this.options.transport.send(message);
}
/**
* Send a rule message to the server using separate rule identifier and content.
*
* @param ruleIdentifier - The rule identifier (alphanumeric with underscores/hyphens)
* @param content - The message content
* @returns True if message was sent, false if not connected/authenticated or invalid format
*/
sendRuleMessage(ruleIdentifier: string, content: string): boolean {
if (!this.options.transport.isConnected || !this.options.transport.isAuthenticated) {
return false;
}
try {
const encoded = encodeRuleMessage(ruleIdentifier, content);
return this.options.transport.send(encoded);
} catch {
return false;
}
}
} }
export function createYonexusClientRuntime( export function createYonexusClientRuntime(

View File

@@ -40,6 +40,7 @@ export class YonexusClientTransport implements ClientTransport {
private reconnectAttempts = 0; private reconnectAttempts = 0;
private reconnectTimer: NodeJS.Timeout | null = null; private reconnectTimer: NodeJS.Timeout | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null; private heartbeatTimer: NodeJS.Timeout | null = null;
private shouldReconnect = false;
// Reconnect configuration // Reconnect configuration
private readonly maxReconnectAttempts = 10; private readonly maxReconnectAttempts = 10;
@@ -67,6 +68,8 @@ export class YonexusClientTransport implements ClientTransport {
return; return;
} }
this.shouldReconnect = true;
this.clearReconnectTimer();
this.setState("connecting"); this.setState("connecting");
const { mainHost } = this.options.config; const { mainHost } = this.options.config;
@@ -75,12 +78,13 @@ export class YonexusClientTransport implements ClientTransport {
this.ws = new WebSocket(mainHost); this.ws = new WebSocket(mainHost);
const onOpen = () => { const onOpen = () => {
this.ws?.off("error", onInitialError);
this.setState("connected"); this.setState("connected");
this.reconnectAttempts = 0; // Reset on successful connection this.reconnectAttempts = 0; // Reset on successful connection
resolve(); resolve();
}; };
const onError = (error: Error) => { const onInitialError = (error: Error) => {
this.setState("error"); this.setState("error");
if (this.options.onError) { if (this.options.onError) {
this.options.onError(error); this.options.onError(error);
@@ -89,7 +93,7 @@ export class YonexusClientTransport implements ClientTransport {
}; };
this.ws.once("open", onOpen); this.ws.once("open", onOpen);
this.ws.once("error", onError); this.ws.once("error", onInitialError);
this.ws.on("message", (data) => { this.ws.on("message", (data) => {
const message = data.toString("utf8"); const message = data.toString("utf8");
@@ -114,6 +118,7 @@ export class YonexusClientTransport implements ClientTransport {
} }
disconnect(): void { disconnect(): void {
this.shouldReconnect = false;
this.clearReconnectTimer(); this.clearReconnectTimer();
this.stopHeartbeat(); this.stopHeartbeat();
@@ -153,18 +158,16 @@ export class YonexusClientTransport implements ClientTransport {
} }
private handleDisconnect(code: number, reason: string): void { private handleDisconnect(code: number, reason: string): void {
const wasAuthenticated = this._state === "authenticated";
this.ws = null; this.ws = null;
this.stopHeartbeat(); this.stopHeartbeat();
this.setState("disconnected"); this.setState("disconnected");
// Don't reconnect if it was a normal close // Don't reconnect if it was a normal close or caller explicitly stopped reconnects.
if (code === 1000) { if (code === 1000 || !this.shouldReconnect) {
return; return;
} }
// Attempt reconnect if we were previously authenticated if (this.reconnectAttempts < this.maxReconnectAttempts) {
if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect(); this.scheduleReconnect();
} }
} }