From 3eed62b63578c7bc6c4df4724ec43da526ae84d3 Mon Sep 17 00:00:00 2001 From: Jacob Schlecht Date: Mon, 5 Oct 2026 20:02:13 -0600 Subject: [PATCH] fix: Increase event websocket reliability Signed-off-by: Jacob Schlecht --- src/Client.ts | 14 +++++-- src/events/EventClient.ts | 85 +++++++++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 74d952ae..a1a3602d 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -3,7 +3,12 @@ import { batch, createSignal } from "solid-js"; import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter"; import { API } from "stoat-api"; -import type { DataLogin, Error, RevoltConfig, Role } from "stoat-api"; +import type { + DataLogin, + RevoltConfig, + Role, + Error as StoatError, +} from "stoat-api"; import type { Channel } from "./classes/Channel.js"; import type { Emoji } from "./classes/Emoji.js"; @@ -47,7 +52,7 @@ export type Session = { _id: string; token: string; user_id: string } | string; * Events provided by the client */ export type Events = { - error: [error: Error]; + error: [error: StoatError | Error | Event]; connected: []; connecting: []; @@ -278,7 +283,9 @@ export class Client extends AsyncEventEmitter { this.users = new UserCollection(this); this.events = new EventClient(1, "json", this.options); - this.events.on("error", (error) => this.emit("error", error)); + this.events.on("error", (error) => + this.listenerCount("error") > 0 ? this.emit("error", error) : undefined, + ); this.events.on("state", (state) => { switch (state) { case ConnectionState.Connected: @@ -332,7 +339,6 @@ export class Client extends AsyncEventEmitter { */ connect(): void { clearTimeout(this.#reconnectTimeout); - this.events.disconnect(); this.#setReady(false); this.events.connect( this.configuration?.ws ?? "wss://stoat.chat/events", diff --git a/src/events/EventClient.ts b/src/events/EventClient.ts index 93b620a3..21ae406a 100644 --- a/src/events/EventClient.ts +++ b/src/events/EventClient.ts @@ -3,7 +3,7 @@ import { createSignal } from "solid-js"; import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter"; import { JSONParse, JSONStringify } from "json-with-bigint"; -import type { Error } from "stoat-api"; +import type { Error as StoatError } from "stoat-api"; import type { ProtocolV1 } from "./v1.js"; @@ -67,7 +67,7 @@ export interface EventClientOptions { * Events provided by the client. */ type Events> = { - error: [error: Error]; + error: [error: StoatError | Error | Event]; event: [event: P["server"]]; state: [state: ConnectionState]; }; @@ -94,8 +94,11 @@ export class EventClient< #pongTimeoutReference: number | undefined; #connectTimeoutReference: number | undefined; - #lastError: // eslint-disable-next-line @typescript-eslint/no-explicit-any - { type: "socket"; data: any } | { type: "revolt"; data: Error } | undefined; + #lastError: + | { type: "socket"; data: Event } + | { type: "revolt"; data: StoatError } + | { type: "protocol"; data: Error } + | undefined; /** * Create a new event client. @@ -130,6 +133,10 @@ export class EventClient< this.#setPing = setPing; this.disconnect = this.disconnect.bind(this); + this.dispose = this.dispose.bind(this); + this.send = this.send.bind(this); + this.connect = this.connect.bind(this); + this.handle = this.handle.bind(this); } /** @@ -141,19 +148,32 @@ export class EventClient< this.emit("state", state); } + /** + * Clear all pending connection timers. Call on disconnect. + * Idempotent — safe to call multiple times. + */ + private clearTimers(): void { + clearInterval(this.#heartbeatIntervalReference); + clearTimeout(this.#connectTimeoutReference); + clearTimeout(this.#pongTimeoutReference); + this.#heartbeatIntervalReference = undefined; + this.#connectTimeoutReference = undefined; + this.#pongTimeoutReference = undefined; + } + /** * Connect to the websocket service. * @param uri WebSocket URI * @param token Authentication token */ connect(uri: string, token: string): void { - this.disconnect(); + this.dispose(); this.#lastError = undefined; this.setState(ConnectionState.Connecting); this.#connectTimeoutReference = setTimeout( () => this.disconnect(), - this.options.pongTimeout * 1e3, + this.options.connectTimeout * 1e3, ) as never; const url = new URL(uri); @@ -175,9 +195,10 @@ export class EventClient< // url.searchParams.append("ready", "unreads or something"); // url.searchParams.append("ready", "policy_changes"); - this.#socket = new WebSocket(url); + const socket = (this.#socket = new WebSocket(url)); - this.#socket.onopen = () => { + socket.onopen = () => { + if (this.#socket !== socket) return; // stale, a newer socket exists this.#heartbeatIntervalReference = setInterval(() => { this.send({ type: "Ping", data: +new Date() }); this.#pongTimeoutReference = setTimeout( @@ -187,44 +208,61 @@ export class EventClient< }, this.options.heartbeatInterval * 1e3) as never; }; - this.#socket.onerror = (error) => { + socket.onerror = (error) => { + if (this.#socket !== socket) return; // stale, a newer socket exists this.#lastError = { type: "socket", data: error }; - this.emit("error", error as never); + this.emit("error", error); }; - this.#socket.onmessage = (event) => { + socket.onmessage = (event) => { + if (this.#socket !== socket) return; // stale, a newer socket exists clearInterval(this.#connectTimeoutReference); if (this.#transportFormat === "json") { if (typeof event.data === "string") { - this.handle(JSONParse(event.data)); + try { + this.handle(JSONParse(event.data)); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + this.#lastError = { type: "protocol", data: error }; + this.disconnect(); + this.emit("error", error); + } } } }; let closed = false; - this.#socket.onclose = () => { + socket.onclose = () => { if (closed) return; closed = true; + if (this.#socket !== socket) return; // stale, a newer socket exists this.#socket = undefined; + this.clearTimers(); this.setState(ConnectionState.Disconnected); - this.disconnect(); }; } /** - * Disconnect the websocket client. + * Dispose the websocket client. */ - disconnect(): void { + dispose(): void { if (!this.#socket) return; - clearInterval(this.#heartbeatIntervalReference); - clearInterval(this.#connectTimeoutReference); - clearInterval(this.#pongTimeoutReference); + this.clearTimers(); const socket = this.#socket; this.#socket = undefined; socket.close(); } + /** + * Disconnect the websocket client. + */ + disconnect(): void { + if (!this.#socket) return; + this.dispose(); + this.setState(ConnectionState.Disconnected); + } + /** * Send an event to the server. * @param event Event @@ -258,8 +296,8 @@ export class EventClient< type: "revolt", data: event.data, }; - this.emit("error", event.data); this.disconnect(); + this.emit("error", event.data); return; } @@ -292,11 +330,14 @@ export class EventClient< get lastError(): | { type: "socket"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; + data: Event; } | { type: "revolt"; + data: StoatError; + } + | { + type: "protocol"; data: Error; } | undefined {