Harden client reconnect and protocol guards
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user