Harden client reconnect and protocol guards
This commit is contained in:
@@ -4,11 +4,14 @@ import {
|
||||
buildHeartbeat,
|
||||
buildHello,
|
||||
buildPairConfirm,
|
||||
CodecError,
|
||||
createAuthRequestSigningInput,
|
||||
decodeBuiltin,
|
||||
encodeBuiltin,
|
||||
encodeRuleMessage,
|
||||
isBuiltinMessage,
|
||||
type AuthFailedPayload,
|
||||
type BuiltinPayloadMap,
|
||||
type HelloAckPayload,
|
||||
type PairFailedPayload,
|
||||
type PairRequestPayload,
|
||||
@@ -118,7 +121,15 @@ export class YonexusClientRuntime {
|
||||
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") {
|
||||
this.handleHelloAck(envelope as TypedBuiltinEnvelope<"hello_ack">);
|
||||
return;
|
||||
@@ -160,6 +171,8 @@ export class YonexusClientRuntime {
|
||||
await this.handleRePairRequired();
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPairingFailure = `unsupported_builtin:${String(envelope.type)}`;
|
||||
}
|
||||
|
||||
handleTransportStateChange(state: ClientConnectionState): void {
|
||||
@@ -169,6 +182,7 @@ export class YonexusClientRuntime {
|
||||
|
||||
if (state === "disconnected") {
|
||||
this.phase = hasClientSecret(this.clientState) ? "auth_required" : "idle";
|
||||
this.pendingPairing = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +305,11 @@ export class YonexusClientRuntime {
|
||||
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;
|
||||
await this.resetTrustState();
|
||||
return;
|
||||
@@ -375,6 +393,57 @@ export class YonexusClientRuntime {
|
||||
await this.options.stateStore.save(this.clientState);
|
||||
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(
|
||||
|
||||
@@ -40,6 +40,7 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private shouldReconnect = false;
|
||||
|
||||
// Reconnect configuration
|
||||
private readonly maxReconnectAttempts = 10;
|
||||
@@ -67,6 +68,8 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.clearReconnectTimer();
|
||||
this.setState("connecting");
|
||||
const { mainHost } = this.options.config;
|
||||
|
||||
@@ -75,12 +78,13 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
this.ws = new WebSocket(mainHost);
|
||||
|
||||
const onOpen = () => {
|
||||
this.ws?.off("error", onInitialError);
|
||||
this.setState("connected");
|
||||
this.reconnectAttempts = 0; // Reset on successful connection
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
const onInitialError = (error: Error) => {
|
||||
this.setState("error");
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error);
|
||||
@@ -89,7 +93,7 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
};
|
||||
|
||||
this.ws.once("open", onOpen);
|
||||
this.ws.once("error", onError);
|
||||
this.ws.once("error", onInitialError);
|
||||
|
||||
this.ws.on("message", (data) => {
|
||||
const message = data.toString("utf8");
|
||||
@@ -114,6 +118,7 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearReconnectTimer();
|
||||
this.stopHeartbeat();
|
||||
|
||||
@@ -153,18 +158,16 @@ export class YonexusClientTransport implements ClientTransport {
|
||||
}
|
||||
|
||||
private handleDisconnect(code: number, reason: string): void {
|
||||
const wasAuthenticated = this._state === "authenticated";
|
||||
this.ws = null;
|
||||
this.stopHeartbeat();
|
||||
this.setState("disconnected");
|
||||
|
||||
// Don't reconnect if it was a normal close
|
||||
if (code === 1000) {
|
||||
// Don't reconnect if it was a normal close or caller explicitly stopped reconnects.
|
||||
if (code === 1000 || !this.shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnect if we were previously authenticated
|
||||
if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user