mirror of
https://github.com/ReVanced/revanced-bots.git
synced 2026-01-11 13:56:15 +00:00
fix(packages/api)!: handle dead connections better
This commit is contained in:
@@ -1,30 +1,22 @@
|
||||
import { ClientOperation, Packet, ServerOperation } from '@revanced/bot-shared'
|
||||
import ClientGateway, { ClientGatewayEventHandlers } from './ClientGateway'
|
||||
import { ClientWebSocketManager, ClientWebSocketEvents, ClientWebSocketManagerOptions } from './ClientWebSocket'
|
||||
|
||||
/**
|
||||
* The client that connects to the API.
|
||||
*/
|
||||
export default class Client {
|
||||
ready = false
|
||||
gateway: ClientGateway
|
||||
ws: ClientWebSocketManager
|
||||
#parseId = 0
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.gateway = new ClientGateway({
|
||||
url: options.api.gatewayUrl,
|
||||
})
|
||||
|
||||
this.gateway.on('ready', () => {
|
||||
this.ws = new ClientWebSocketManager(options.api.websocket)
|
||||
this.ws.on('ready', () => {
|
||||
this.ready = true
|
||||
})
|
||||
}
|
||||
this.ws.on('disconnect', () => {
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket API
|
||||
* @returns A promise that resolves when the client is ready
|
||||
*/
|
||||
connect() {
|
||||
return this.gateway.connect()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +37,7 @@ export default class Client {
|
||||
|
||||
const currentId = (this.#parseId++).toString()
|
||||
|
||||
this.gateway.send({
|
||||
this.ws.send({
|
||||
op: ClientOperation.ParseText,
|
||||
d: {
|
||||
text,
|
||||
@@ -58,18 +50,18 @@ export default class Client {
|
||||
const promise = new Promise<CorrectPacket['d']>((rs, rj) => {
|
||||
const parsedTextListener = (packet: CorrectPacket) => {
|
||||
if (packet.d.id !== currentId) return
|
||||
this.gateway.off('parsedText', parsedTextListener)
|
||||
this.ws.off('parsedText', parsedTextListener)
|
||||
rs(packet.d)
|
||||
}
|
||||
|
||||
const parseTextFailedListener = (packet: Packet<ServerOperation.ParseTextFailed>) => {
|
||||
if (packet.d.id !== currentId) return
|
||||
this.gateway.off('parseTextFailed', parseTextFailedListener)
|
||||
this.ws.off('parseTextFailed', parseTextFailedListener)
|
||||
rj()
|
||||
}
|
||||
|
||||
this.gateway.on('parsedText', parsedTextListener)
|
||||
this.gateway.on('parseTextFailed', parseTextFailedListener)
|
||||
this.ws.on('parsedText', parsedTextListener)
|
||||
this.ws.on('parseTextFailed', parseTextFailedListener)
|
||||
})
|
||||
|
||||
return await promise
|
||||
@@ -85,7 +77,7 @@ export default class Client {
|
||||
|
||||
const currentId = (this.#parseId++).toString()
|
||||
|
||||
this.gateway.send({
|
||||
this.ws.send({
|
||||
op: ClientOperation.ParseImage,
|
||||
d: {
|
||||
image_url: url,
|
||||
@@ -98,18 +90,18 @@ export default class Client {
|
||||
const promise = new Promise<CorrectPacket['d']>((rs, rj) => {
|
||||
const parsedImageListener = (packet: CorrectPacket) => {
|
||||
if (packet.d.id !== currentId) return
|
||||
this.gateway.off('parsedImage', parsedImageListener)
|
||||
this.ws.off('parsedImage', parsedImageListener)
|
||||
rs(packet.d)
|
||||
}
|
||||
|
||||
const parseImageFailedListener = (packet: Packet<ServerOperation.ParseImageFailed>) => {
|
||||
if (packet.d.id !== currentId) return
|
||||
this.gateway.off('parseImageFailed', parseImageFailedListener)
|
||||
this.ws.off('parseImageFailed', parseImageFailedListener)
|
||||
rj()
|
||||
}
|
||||
|
||||
this.gateway.on('parsedImage', parsedImageListener)
|
||||
this.gateway.on('parseImageFailed', parseImageFailedListener)
|
||||
this.ws.on('parsedImage', parsedImageListener)
|
||||
this.ws.on('parseImageFailed', parseImageFailedListener)
|
||||
})
|
||||
|
||||
return await promise
|
||||
@@ -121,8 +113,8 @@ export default class Client {
|
||||
* @param handler The event handler
|
||||
* @returns The event handler function
|
||||
*/
|
||||
on<TOpName extends keyof ClientGatewayEventHandlers>(name: TOpName, handler: ClientGatewayEventHandlers[TOpName]) {
|
||||
this.gateway.on(name, handler)
|
||||
on<TOpName extends keyof ClientWebSocketEvents>(name: TOpName, handler: ClientWebSocketEvents[TOpName]) {
|
||||
this.ws.on(name, handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -132,8 +124,8 @@ export default class Client {
|
||||
* @param handler The event handler to remove
|
||||
* @returns The removed event handler function
|
||||
*/
|
||||
off<TOpName extends keyof ClientGatewayEventHandlers>(name: TOpName, handler: ClientGatewayEventHandlers[TOpName]) {
|
||||
this.gateway.off(name, handler)
|
||||
off<TOpName extends keyof ClientWebSocketEvents>(name: TOpName, handler: ClientWebSocketEvents[TOpName]) {
|
||||
this.ws.off(name, handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -143,11 +135,11 @@ export default class Client {
|
||||
* @param handler The event handler
|
||||
* @returns The event handler function
|
||||
*/
|
||||
once<TOpName extends keyof ClientGatewayEventHandlers>(
|
||||
once<TOpName extends keyof ClientWebSocketEvents>(
|
||||
name: TOpName,
|
||||
handler: ClientGatewayEventHandlers[TOpName],
|
||||
handler: ClientWebSocketEvents[TOpName],
|
||||
) {
|
||||
this.gateway.once(name, handler)
|
||||
this.ws.once(name, handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -163,5 +155,5 @@ export interface ClientOptions {
|
||||
}
|
||||
|
||||
export interface ClientApiOptions {
|
||||
gatewayUrl: string
|
||||
websocket: ClientWebSocketManagerOptions
|
||||
}
|
||||
|
||||
@@ -16,18 +16,21 @@ import { RawData, WebSocket } from 'ws'
|
||||
* The class that handles the WebSocket connection to the server.
|
||||
* This is the only relevant class for the time being. But in the future, there may be more classes to handle different protocols of the API.
|
||||
*/
|
||||
export default class ClientGateway {
|
||||
export class ClientWebSocketManager {
|
||||
readonly url: string
|
||||
timeout: number
|
||||
|
||||
ready = false
|
||||
disconnected: boolean | DisconnectReason = DisconnectReason.NeverConnected
|
||||
config: Readonly<Packet<ServerOperation.Hello>['d']> | null = null!
|
||||
config: Readonly<Packet<ServerOperation.Hello>['d']> | null = null
|
||||
|
||||
#hbTimeout: NodeJS.Timeout = null!
|
||||
#socket: WebSocket = null!
|
||||
#emitter = new EventEmitter() as TypedEmitter<ClientGatewayEventHandlers>
|
||||
#emitter = new EventEmitter() as TypedEmitter<ClientWebSocketEvents>
|
||||
|
||||
constructor(options: ClientGatewayOptions) {
|
||||
constructor(options: ClientWebSocketManagerOptions) {
|
||||
this.url = options.url
|
||||
this.timeout = options.timeout ?? 10000
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +42,11 @@ export default class ClientGateway {
|
||||
try {
|
||||
this.#socket = new WebSocket(this.url)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.ready) throw new Error('WebSocket connection timed out')
|
||||
this.#socket.close()
|
||||
}, this.timeout)
|
||||
|
||||
this.#socket.on('open', () => {
|
||||
this.disconnected = false
|
||||
this.#listen()
|
||||
@@ -47,10 +55,14 @@ export default class ClientGateway {
|
||||
rs()
|
||||
})
|
||||
|
||||
const errorHandler = () => this.#handleDisconnect(DisconnectReason.Generic)
|
||||
this.#socket.on('error', (err) => {
|
||||
throw err
|
||||
})
|
||||
|
||||
this.#socket.on('close', errorHandler)
|
||||
this.#socket.on('error', errorHandler)
|
||||
this.#socket.on('close', (code, reason) => {
|
||||
if (code === 1006) throw new Error(`Failed to connect to WebSocket server: ${reason}`)
|
||||
this.#handleDisconnect(DisconnectReason.Generic)
|
||||
})
|
||||
} catch (e) {
|
||||
rj(e)
|
||||
}
|
||||
@@ -63,9 +75,9 @@ export default class ClientGateway {
|
||||
* @param handler The event handler
|
||||
* @returns The event handler function
|
||||
*/
|
||||
on<TOpName extends keyof ClientGatewayEventHandlers>(
|
||||
on<TOpName extends keyof ClientWebSocketEvents>(
|
||||
name: TOpName,
|
||||
handler: ClientGatewayEventHandlers[typeof name],
|
||||
handler: ClientWebSocketEvents[typeof name],
|
||||
) {
|
||||
this.#emitter.on(name, handler)
|
||||
}
|
||||
@@ -76,9 +88,9 @@ export default class ClientGateway {
|
||||
* @param handler The event handler to remove
|
||||
* @returns The removed event handler function
|
||||
*/
|
||||
off<TOpName extends keyof ClientGatewayEventHandlers>(
|
||||
off<TOpName extends keyof ClientWebSocketEvents>(
|
||||
name: TOpName,
|
||||
handler: ClientGatewayEventHandlers[typeof name],
|
||||
handler: ClientWebSocketEvents[typeof name],
|
||||
) {
|
||||
this.#emitter.off(name, handler)
|
||||
}
|
||||
@@ -89,9 +101,9 @@ export default class ClientGateway {
|
||||
* @param handler The event handler
|
||||
* @returns The event handler function
|
||||
*/
|
||||
once<TOpName extends keyof ClientGatewayEventHandlers>(
|
||||
once<TOpName extends keyof ClientWebSocketEvents>(
|
||||
name: TOpName,
|
||||
handler: ClientGatewayEventHandlers[typeof name],
|
||||
handler: ClientWebSocketEvents[typeof name],
|
||||
) {
|
||||
this.#emitter.once(name, handler)
|
||||
}
|
||||
@@ -114,14 +126,14 @@ export default class ClientGateway {
|
||||
*/
|
||||
disconnect() {
|
||||
this.#throwIfDisconnected('Cannot disconnect when already disconnected from the server')
|
||||
this.#handleDisconnect(DisconnectReason.Generic)
|
||||
this.#handleDisconnect(DisconnectReason.PlannedDisconnect)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the client is ready
|
||||
* @returns Whether the client is ready
|
||||
*/
|
||||
isReady(): this is ReadiedClientGateway {
|
||||
isReady(): this is ReadiedClientWebSocketManager {
|
||||
return this.ready
|
||||
}
|
||||
|
||||
@@ -145,7 +157,7 @@ export default class ClientGateway {
|
||||
return this.#handleDisconnect((packet as Packet<ServerOperation.Disconnect>).d.reason)
|
||||
default:
|
||||
return this.#emitter.emit(
|
||||
uncapitalize(ServerOperation[packet.op] as ClientGatewayServerEventName),
|
||||
uncapitalize(ServerOperation[packet.op] as ClientWebSocketEventName),
|
||||
// @ts-expect-error TypeScript doesn't know that the lines above negate the type enough
|
||||
packet,
|
||||
)
|
||||
@@ -162,6 +174,7 @@ export default class ClientGateway {
|
||||
clearTimeout(this.#hbTimeout)
|
||||
this.disconnected = reason
|
||||
this.#socket.close()
|
||||
this.#socket = null!
|
||||
|
||||
this.#emitter.emit('disconnect', reason)
|
||||
}
|
||||
@@ -190,25 +203,30 @@ export default class ClientGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientGatewayOptions {
|
||||
export interface ClientWebSocketManagerOptions {
|
||||
/**
|
||||
* The gateway URL to connect to
|
||||
* The URL to connect to
|
||||
*/
|
||||
url: string
|
||||
/**
|
||||
* The timeout for the connection
|
||||
* @default 10000
|
||||
*/
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type ClientGatewayServerEventName = keyof typeof ServerOperation
|
||||
export type ClientWebSocketEventName = keyof typeof ServerOperation
|
||||
|
||||
export type ClientGatewayEventHandlers = {
|
||||
[K in Uncapitalize<ClientGatewayServerEventName>]: (
|
||||
export type ClientWebSocketEvents = {
|
||||
[K in Uncapitalize<ClientWebSocketEventName>]: (
|
||||
packet: Packet<(typeof ServerOperation)[Capitalize<K>]>,
|
||||
) => Promise<void> | void
|
||||
} & {
|
||||
hello: (config: NonNullable<ClientGateway['config']>) => Promise<void> | void
|
||||
hello: (config: NonNullable<ClientWebSocketManager['config']>) => Promise<void> | void
|
||||
ready: () => Promise<void> | void
|
||||
packet: (packet: Packet<ServerOperation>) => Promise<void> | void
|
||||
invalidPacket: (packet: Packet) => Promise<void> | void
|
||||
disconnect: (reason: DisconnectReason) => Promise<void> | void
|
||||
}
|
||||
|
||||
export type ReadiedClientGateway = RequiredProperty<InstanceType<typeof ClientGateway>>
|
||||
export type ReadiedClientWebSocketManager = RequiredProperty<InstanceType<typeof ClientWebSocketManager>>
|
||||
@@ -1,4 +1,3 @@
|
||||
export { default as Client } from './Client'
|
||||
export * from './Client'
|
||||
export { default as ClientGateway } from './ClientGateway'
|
||||
export * from './ClientGateway'
|
||||
export * from './ClientWebSocket'
|
||||
|
||||
@@ -22,6 +22,10 @@ enum DisconnectReason {
|
||||
* The client had never connected to the server (**CLIENT-ONLY**)
|
||||
*/
|
||||
NeverConnected = 5,
|
||||
/**
|
||||
* The client disconnected on its own (**CLIENT-ONLY**)
|
||||
*/
|
||||
PlannedDisconnect = 6,
|
||||
}
|
||||
|
||||
export default DisconnectReason
|
||||
|
||||
@@ -9,6 +9,7 @@ const HumanizedDisconnectReason = {
|
||||
[DisconnectReason.TimedOut]: 'has timed out',
|
||||
[DisconnectReason.ServerError]: 'has been disconnected due to an internal server error',
|
||||
[DisconnectReason.NeverConnected]: 'had never connected to the server',
|
||||
[DisconnectReason.PlannedDisconnect]: 'has disconnected on its own',
|
||||
} as const satisfies Record<DisconnectReason, string>
|
||||
|
||||
export default HumanizedDisconnectReason
|
||||
|
||||
Reference in New Issue
Block a user