chore: apply code fixes with biome

This commit is contained in:
PalmDevs
2023-11-28 22:03:41 +07:00
parent c80bd068fa
commit f2d85c32a4
32 changed files with 1384 additions and 1383 deletions

View File

@@ -1,3 +1,3 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
} }

View File

@@ -1,9 +1,9 @@
{ {
"$schema": "./config.schema.json", "$schema": "./config.schema.json",
"address": "127.0.0.1", "address": "127.0.0.1",
"port": 3000, "port": 3000,
"ocrConcurrentQueues": 1, "ocrConcurrentQueues": 1,
"clientHeartbeatInterval": 5000, "clientHeartbeatInterval": 5000,
"debugLogsInProduction": false "debugLogsInProduction": false
} }

View File

@@ -1,31 +1,31 @@
{ {
"$schema": "http://json-schema.org/draft-04/schema#", "$schema": "http://json-schema.org/draft-04/schema#",
"type": "object", "type": "object",
"properties": { "properties": {
"address": { "address": {
"description": "Address to listen on", "description": "Address to listen on",
"type": "string", "type": "string",
"default": "127.0.0.1" "default": "127.0.0.1"
}, },
"port": { "port": {
"description": "Port to listen on", "description": "Port to listen on",
"type": "integer", "type": "integer",
"default": 80 "default": 80
}, },
"ocrConcurrentQueues": { "ocrConcurrentQueues": {
"description": "Number of concurrent queues for OCR", "description": "Number of concurrent queues for OCR",
"type": "integer", "type": "integer",
"default": 1 "default": 1
}, },
"clientHeartbeatInterval": { "clientHeartbeatInterval": {
"description": "Time in milliseconds to wait for a client to send a heartbeat packet, if no packet is received, the server will wait for `clientHeartbeatExtraTime` milliseconds before disconnecting the client", "description": "Time in milliseconds to wait for a client to send a heartbeat packet, if no packet is received, the server will wait for `clientHeartbeatExtraTime` milliseconds before disconnecting the client",
"type": "integer", "type": "integer",
"default": 60000 "default": 60000
}, },
"debugLogsInProduction": { "debugLogsInProduction": {
"description": "Whether to print debug logs in production", "description": "Whether to print debug logs in production",
"type": "boolean", "type": "boolean",
"default": false "default": false
} }
} }
} }

View File

@@ -1,218 +1,218 @@
import { import { EventEmitter } from 'node:events'
ClientOperation, import {
DisconnectReason, ClientOperation,
Packet, DisconnectReason,
ServerOperation, Packet,
deserializePacket, ServerOperation,
isClientPacket, deserializePacket,
serializePacket, isClientPacket,
uncapitalize, serializePacket,
} from '@revanced/bot-shared' uncapitalize,
import { EventEmitter } from 'node:events' } from '@revanced/bot-shared'
import type TypedEmitter from 'typed-emitter' import type TypedEmitter from 'typed-emitter'
import type { RawData, WebSocket } from 'ws' import type { RawData, WebSocket } from 'ws'
export default class Client { export default class Client {
id: string id: string
disconnected: DisconnectReason | false = false disconnected: DisconnectReason | false = false
ready: boolean = false ready = false
lastHeartbeat: number = null! lastHeartbeat: number = null!
heartbeatInterval: number heartbeatInterval: number
#hbTimeout: NodeJS.Timeout = null! #hbTimeout: NodeJS.Timeout = null!
#emitter = new EventEmitter() as TypedEmitter<ClientEventHandlers> #emitter = new EventEmitter() as TypedEmitter<ClientEventHandlers>
#socket: WebSocket #socket: WebSocket
constructor(options: ClientOptions) { constructor(options: ClientOptions) {
this.#socket = options.socket this.#socket = options.socket
this.heartbeatInterval = options.heartbeatInterval ?? 60000 this.heartbeatInterval = options.heartbeatInterval ?? 60000
this.id = options.id this.id = options.id
this.#socket.on('error', () => this.forceDisconnect()) this.#socket.on('error', () => this.forceDisconnect())
this.#socket.on('close', () => this.forceDisconnect()) this.#socket.on('close', () => this.forceDisconnect())
this.#socket.on('unexpected-response', () => this.forceDisconnect()) this.#socket.on('unexpected-response', () => this.forceDisconnect())
this.send({ this.send({
op: ServerOperation.Hello, op: ServerOperation.Hello,
d: { d: {
heartbeatInterval: this.heartbeatInterval, heartbeatInterval: this.heartbeatInterval,
}, },
}) })
.then(() => { .then(() => {
this.#listen() this.#listen()
this.#listenHeartbeat() this.#listenHeartbeat()
this.ready = true this.ready = true
this.#emitter.emit('ready') this.#emitter.emit('ready')
}) })
.catch(() => { .catch(() => {
if (this.disconnected === false) if (this.disconnected === false)
this.disconnect(DisconnectReason.ServerError) this.disconnect(DisconnectReason.ServerError)
else this.forceDisconnect(DisconnectReason.ServerError) else this.forceDisconnect(DisconnectReason.ServerError)
}) })
} }
on<TOpName extends keyof ClientEventHandlers>( on<TOpName extends keyof ClientEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientEventHandlers[typeof name] handler: ClientEventHandlers[typeof name],
) { ) {
this.#emitter.on(name, handler) this.#emitter.on(name, handler)
} }
once<TOpName extends keyof ClientEventHandlers>( once<TOpName extends keyof ClientEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientEventHandlers[typeof name] handler: ClientEventHandlers[typeof name],
) { ) {
this.#emitter.once(name, handler) this.#emitter.once(name, handler)
} }
off<TOpName extends keyof ClientEventHandlers>( off<TOpName extends keyof ClientEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientEventHandlers[typeof name] handler: ClientEventHandlers[typeof name],
) { ) {
this.#emitter.off(name, handler) this.#emitter.off(name, handler)
} }
send<TOp extends ServerOperation>(packet: Packet<TOp>) { send<TOp extends ServerOperation>(packet: Packet<TOp>) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
try { try {
this.#throwIfDisconnected( this.#throwIfDisconnected(
'Cannot send packet to client that has already disconnected' 'Cannot send packet to client that has already disconnected',
) )
this.#socket.send(serializePacket(packet), err => this.#socket.send(serializePacket(packet), err =>
err ? reject(err) : resolve() err ? reject(err) : resolve(),
) )
} catch (e) { } catch (e) {
reject(e) reject(e)
} }
}) })
} }
async disconnect(reason: DisconnectReason = DisconnectReason.Generic) { async disconnect(reason: DisconnectReason = DisconnectReason.Generic) {
this.#throwIfDisconnected( this.#throwIfDisconnected(
'Cannot disconnect client that has already disconnected' 'Cannot disconnect client that has already disconnected',
) )
try { try {
await this.send({ op: ServerOperation.Disconnect, d: { reason } }) await this.send({ op: ServerOperation.Disconnect, d: { reason } })
} catch (err) { } catch (err) {
throw new Error( throw new Error(
`Cannot send disconnect reason to client ${this.id}: ${err}` `Cannot send disconnect reason to client ${this.id}: ${err}`,
) )
} finally { } finally {
this.forceDisconnect(reason) this.forceDisconnect(reason)
} }
} }
forceDisconnect(reason: DisconnectReason = DisconnectReason.Generic) { forceDisconnect(reason: DisconnectReason = DisconnectReason.Generic) {
if (this.disconnected !== false) return if (this.disconnected !== false) return
if (this.#hbTimeout) clearTimeout(this.#hbTimeout) if (this.#hbTimeout) clearTimeout(this.#hbTimeout)
this.#socket.terminate() this.#socket.terminate()
this.ready = false this.ready = false
this.disconnected = reason this.disconnected = reason
this.#emitter.emit('disconnect', reason) this.#emitter.emit('disconnect', reason)
} }
#throwIfDisconnected(errorMessage: string) { #throwIfDisconnected(errorMessage: string) {
if (this.disconnected !== false) throw new Error(errorMessage) if (this.disconnected !== false) throw new Error(errorMessage)
if (this.#socket.readyState !== this.#socket.OPEN) { if (this.#socket.readyState !== this.#socket.OPEN) {
this.forceDisconnect(DisconnectReason.Generic) this.forceDisconnect(DisconnectReason.Generic)
throw new Error(errorMessage) throw new Error(errorMessage)
} }
} }
#listen() { #listen() {
this.#socket.on('message', data => { this.#socket.on('message', data => {
try { try {
const rawPacket = deserializePacket(this._toBuffer(data)) const rawPacket = deserializePacket(this._toBuffer(data))
if (!isClientPacket(rawPacket)) throw null if (!isClientPacket(rawPacket)) throw null
const packet: ClientPacketObject<ClientOperation> = { const packet: ClientPacketObject<ClientOperation> = {
...rawPacket, ...rawPacket,
client: this, client: this,
} }
this.#emitter.emit('packet', packet) this.#emitter.emit('packet', packet)
this.#emitter.emit( this.#emitter.emit(
uncapitalize(ClientOperation[packet.op] as ClientEventName), uncapitalize(ClientOperation[packet.op] as ClientEventName),
// @ts-expect-error TypeScript doesn't know that the above line will negate the type enough // @ts-expect-error TypeScript doesn't know that the above line will negate the type enough
packet packet,
) )
} catch (e) { } catch (e) {
// TODO: add error fields to sent packet so we can log what went wrong // TODO: add error fields to sent packet so we can log what went wrong
this.disconnect(DisconnectReason.InvalidPacket) this.disconnect(DisconnectReason.InvalidPacket)
} }
}) })
} }
#listenHeartbeat() { #listenHeartbeat() {
this.lastHeartbeat = Date.now() this.lastHeartbeat = Date.now()
this.#startHeartbeatTimeout() this.#startHeartbeatTimeout()
this.on('heartbeat', () => { this.on('heartbeat', () => {
this.lastHeartbeat = Date.now() this.lastHeartbeat = Date.now()
this.#hbTimeout.refresh() this.#hbTimeout.refresh()
this.send({ this.send({
op: ServerOperation.HeartbeatAck, op: ServerOperation.HeartbeatAck,
d: { d: {
nextHeartbeat: this.lastHeartbeat + this.heartbeatInterval, nextHeartbeat: this.lastHeartbeat + this.heartbeatInterval,
}, },
}).catch(() => {}) }).catch(() => {})
}) })
} }
#startHeartbeatTimeout() { #startHeartbeatTimeout() {
this.#hbTimeout = setTimeout(() => { this.#hbTimeout = setTimeout(() => {
if (Date.now() - this.lastHeartbeat > 0) { if (Date.now() - this.lastHeartbeat > 0) {
// TODO: put into config // TODO: put into config
// 5000 is extra time to account for latency // 5000 is extra time to account for latency
const interval = setTimeout( const interval = setTimeout(
() => this.disconnect(DisconnectReason.TimedOut), () => this.disconnect(DisconnectReason.TimedOut),
5000 5000,
) )
this.once('heartbeat', () => clearTimeout(interval)) this.once('heartbeat', () => clearTimeout(interval))
// This should never happen but it did in my testing so I'm adding this just in case // This should never happen but it did in my testing so I'm adding this just in case
this.once('disconnect', () => clearTimeout(interval)) this.once('disconnect', () => clearTimeout(interval))
// Technically we don't have to do this, but JUST IN CASE! // Technically we don't have to do this, but JUST IN CASE!
} else this.#hbTimeout.refresh() } else this.#hbTimeout.refresh()
}, this.heartbeatInterval) }, this.heartbeatInterval)
} }
protected _toBuffer(data: RawData) { protected _toBuffer(data: RawData) {
if (data instanceof Buffer) return data if (data instanceof Buffer) return data
else if (data instanceof ArrayBuffer) return Buffer.from(data) else if (data instanceof ArrayBuffer) return Buffer.from(data)
else return Buffer.concat(data) else return Buffer.concat(data)
} }
} }
export interface ClientOptions { export interface ClientOptions {
id: string id: string
socket: WebSocket socket: WebSocket
heartbeatInterval?: number heartbeatInterval?: number
} }
export type ClientPacketObject<TOp extends ClientOperation> = Packet<TOp> & { export type ClientPacketObject<TOp extends ClientOperation> = Packet<TOp> & {
client: Client client: Client
} }
export type ClientEventName = keyof typeof ClientOperation export type ClientEventName = keyof typeof ClientOperation
export type ClientEventHandlers = { export type ClientEventHandlers = {
[K in Uncapitalize<ClientEventName>]: ( [K in Uncapitalize<ClientEventName>]: (
packet: ClientPacketObject<(typeof ClientOperation)[Capitalize<K>]> packet: ClientPacketObject<typeof ClientOperation[Capitalize<K>]>,
) => Promise<void> | void ) => Promise<void> | void
} & { } & {
ready: () => Promise<void> | void ready: () => Promise<void> | void
packet: ( packet: (
packet: ClientPacketObject<ClientOperation> packet: ClientPacketObject<ClientOperation>,
) => Promise<void> | void ) => Promise<void> | void
disconnect: (reason: DisconnectReason) => Promise<void> | void disconnect: (reason: DisconnectReason) => Promise<void> | void
} }

View File

@@ -1,20 +1,20 @@
import type { ClientOperation } from '@revanced/bot-shared' import type { ClientOperation } from '@revanced/bot-shared'
import type { Wit } from 'node-wit' import type { Wit } from 'node-wit'
import { ClientPacketObject } from '../classes/Client.js' import type { Worker as TesseractWorker } from 'tesseract.js'
import type { Config } from '../utils/getConfig.js' import { ClientPacketObject } from '../classes/Client.js'
import type { Logger } from '../utils/logger.js' import type { Config } from '../utils/getConfig.js'
import type { Worker as TesseractWorker } from 'tesseract.js' import type { Logger } from '../utils/logger.js'
export { default as parseTextEventHandler } from './parseText.js' export { default as parseTextEventHandler } from './parseText.js'
export { default as parseImageEventHandler } from './parseImage.js' export { default as parseImageEventHandler } from './parseImage.js'
export type EventHandler<POp extends ClientOperation> = ( export type EventHandler<POp extends ClientOperation> = (
packet: ClientPacketObject<POp>, packet: ClientPacketObject<POp>,
context: EventContext context: EventContext,
) => void | Promise<void> ) => void | Promise<void>
export type EventContext = { export type EventContext = {
witClient: Wit witClient: Wit
tesseractWorker: TesseractWorker tesseractWorker: TesseractWorker
logger: Logger logger: Logger
config: Config config: Config
} }

View File

@@ -1,63 +1,63 @@
import { ClientOperation, ServerOperation } from '@revanced/bot-shared' import { ClientOperation, ServerOperation } from '@revanced/bot-shared'
import { AsyncQueue } from '@sapphire/async-queue' import { AsyncQueue } from '@sapphire/async-queue'
import type { EventHandler } from './index.js' import type { EventHandler } from './index.js'
const queue = new AsyncQueue() const queue = new AsyncQueue()
const parseImageEventHandler: EventHandler<ClientOperation.ParseImage> = async ( const parseImageEventHandler: EventHandler<ClientOperation.ParseImage> = async (
packet, packet,
{ tesseractWorker, logger, config } { tesseractWorker, logger, config },
) => { ) => {
const { const {
client, client,
d: { image_url: imageUrl, id }, d: { image_url: imageUrl, id },
} = packet } = packet
logger.debug( logger.debug(
`Client ${client.id} requested to parse image from URL:`, `Client ${client.id} requested to parse image from URL:`,
imageUrl imageUrl,
) )
logger.debug( logger.debug(
`Queue currently has ${queue.remaining}/${config.ocrConcurrentQueues} items in it` `Queue currently has ${queue.remaining}/${config.ocrConcurrentQueues} items in it`,
) )
if (queue.remaining < config.ocrConcurrentQueues) queue.shift() if (queue.remaining < config.ocrConcurrentQueues) queue.shift()
await queue.wait() await queue.wait()
try { try {
logger.debug(`Recognizing image from URL for client ${client.id}`) logger.debug(`Recognizing image from URL for client ${client.id}`)
const { data, jobId } = await tesseractWorker.recognize(imageUrl) const { data, jobId } = await tesseractWorker.recognize(imageUrl)
logger.debug( logger.debug(
`Recognized image from URL for client ${client.id} (job ${jobId}):`, `Recognized image from URL for client ${client.id} (job ${jobId}):`,
data.text data.text,
) )
await client.send({ await client.send({
op: ServerOperation.ParsedImage, op: ServerOperation.ParsedImage,
d: { d: {
id, id,
text: data.text, text: data.text,
}, },
}) })
} catch { } catch {
logger.error( logger.error(
`Failed to parse image from URL for client ${client.id}:`, `Failed to parse image from URL for client ${client.id}:`,
imageUrl imageUrl,
) )
await client.send({ await client.send({
op: ServerOperation.ParseImageFailed, op: ServerOperation.ParseImageFailed,
d: { d: {
id, id,
}, },
}) })
} finally { } finally {
queue.shift() queue.shift()
logger.debug( logger.debug(
`Finished processing image from URL for client ${client.id}, queue has ${queue.remaining}/${config.ocrConcurrentQueues} remaining items in it` `Finished processing image from URL for client ${client.id}, queue has ${queue.remaining}/${config.ocrConcurrentQueues} remaining items in it`,
) )
} }
} }
export default parseImageEventHandler export default parseImageEventHandler

View File

@@ -1,43 +1,43 @@
import { ClientOperation, ServerOperation } from '@revanced/bot-shared' import { ClientOperation, ServerOperation } from '@revanced/bot-shared'
import { inspect as inspectObject } from 'node:util' import { inspect as inspectObject } from 'node:util'
import type { EventHandler } from './index.js' import type { EventHandler } from './index.js'
const parseTextEventHandler: EventHandler<ClientOperation.ParseText> = async ( const parseTextEventHandler: EventHandler<ClientOperation.ParseText> = async (
packet, packet,
{ witClient, logger } { witClient, logger },
) => { ) => {
const { const {
client, client,
d: { text, id }, d: { text, id },
} = packet } = packet
logger.debug(`Client ${client.id} requested to parse text:`, text) logger.debug(`Client ${client.id} requested to parse text:`, text)
try { try {
const { intents } = await witClient.message(text, {}) const { intents } = await witClient.message(text, {})
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const intentsWithoutIds = intents.map(({ id, ...rest }) => rest) const intentsWithoutIds = intents.map(({ id, ...rest }) => rest)
await client.send({ await client.send({
op: ServerOperation.ParsedText, op: ServerOperation.ParsedText,
d: { d: {
id, id,
labels: intentsWithoutIds, labels: intentsWithoutIds,
}, },
}) })
} catch (e) { } catch (e) {
await client.send({ await client.send({
op: ServerOperation.ParseTextFailed, op: ServerOperation.ParseTextFailed,
d: { d: {
id, id,
}, },
}) })
if (e instanceof Error) logger.error(e.stack ?? e.message) if (e instanceof Error) logger.error(e.stack ?? e.message)
else logger.error(inspectObject(e)) else logger.error(inspectObject(e))
} }
} }
export default parseTextEventHandler export default parseTextEventHandler

View File

@@ -1,157 +1,157 @@
import { fastify } from 'fastify' import fastifyWebsocket from '@fastify/websocket'
import fastifyWebsocket from '@fastify/websocket' import { fastify } from 'fastify'
import { createWorker as createTesseractWorker } from 'tesseract.js' import witPkg from 'node-wit'
import witPkg from 'node-wit' import { createWorker as createTesseractWorker } from 'tesseract.js'
const { Wit } = witPkg const { Wit } = witPkg
import { inspect as inspectObject } from 'node:util' import { inspect as inspectObject } from 'node:util'
import Client from './classes/Client.js' import Client from './classes/Client.js'
import { import {
EventContext, EventContext,
parseImageEventHandler, parseImageEventHandler,
parseTextEventHandler, parseTextEventHandler,
} from './events/index.js' } from './events/index.js'
import { getConfig, checkEnv, logger } from './utils/index.js' import {
import { WebSocket } from 'ws' DisconnectReason,
import { HumanizedDisconnectReason,
DisconnectReason, } from '@revanced/bot-shared'
HumanizedDisconnectReason, import { WebSocket } from 'ws'
} from '@revanced/bot-shared' import { checkEnv, getConfig, logger } from './utils/index.js'
// Check environment variables and load config // Check environment variables and load config
const environment = checkEnv(logger) const environment = checkEnv(logger)
const config = getConfig() const config = getConfig()
if (!config.debugLogsInProduction && environment === 'production') if (!config.debugLogsInProduction && environment === 'production')
logger.debug = () => {} logger.debug = () => {}
// Workers and API clients // Workers and API clients
const tesseractWorker = await createTesseractWorker('eng') const tesseractWorker = await createTesseractWorker('eng')
const witClient = new Wit({ const witClient = new Wit({
accessToken: process.env['WIT_AI_TOKEN']!, accessToken: process.env['WIT_AI_TOKEN']!,
}) })
process.on('beforeExit', () => tesseractWorker.terminate()) process.on('beforeExit', () => tesseractWorker.terminate())
// Server logic // Server logic
const clients = new Set<Client>() const clients = new Set<Client>()
const clientSocketMap = new WeakMap<WebSocket, Client>() const clientSocketMap = new WeakMap<WebSocket, Client>()
const eventContext: EventContext = { const eventContext: EventContext = {
tesseractWorker, tesseractWorker,
logger, logger,
witClient, witClient,
config, config,
} }
const server = fastify() const server = fastify()
.register(fastifyWebsocket, { .register(fastifyWebsocket, {
options: { options: {
// 16 KiB max payload // 16 KiB max payload
// A Discord message can not be longer than 4000 characters // A Discord message can not be longer than 4000 characters
// OCR should not be longer than 16000 characters // OCR should not be longer than 16000 characters
maxPayload: 16 * 1024, maxPayload: 16 * 1024,
}, },
}) })
.register(async instance => { .register(async instance => {
instance.get('/', { websocket: true }, async (connection, request) => { instance.get('/', { websocket: true }, async (connection, request) => {
try { try {
const client = new Client({ const client = new Client({
socket: connection.socket, socket: connection.socket,
id: request.hostname, id: request.hostname,
heartbeatInterval: config.clientHeartbeatInterval, heartbeatInterval: config.clientHeartbeatInterval,
}) })
clientSocketMap.set(connection.socket, client) clientSocketMap.set(connection.socket, client)
clients.add(client) clients.add(client)
logger.debug(`Client ${client.id}'s instance has been added`) logger.debug(`Client ${client.id}'s instance has been added`)
logger.info( logger.info(
`New client connected (now ${clients.size} clients) with ID:`, `New client connected (now ${clients.size} clients) with ID:`,
client.id client.id,
) )
client.on('disconnect', reason => { client.on('disconnect', reason => {
clients.delete(client) clients.delete(client)
logger.info( logger.info(
`Client ${client.id} disconnected because client ${HumanizedDisconnectReason[reason]}` `Client ${client.id} disconnected because client ${HumanizedDisconnectReason[reason]}`,
) )
}) })
client.on('parseText', async packet => client.on('parseText', async packet =>
parseTextEventHandler(packet, eventContext) parseTextEventHandler(packet, eventContext),
) )
client.on('parseImage', async packet => client.on('parseImage', async packet =>
parseImageEventHandler(packet, eventContext) parseImageEventHandler(packet, eventContext),
) )
if ( if (
environment === 'development' && environment === 'development' &&
!config.debugLogsInProduction !config.debugLogsInProduction
) { ) {
logger.debug( logger.debug(
'Running development mode or debug logs in production is enabled, attaching debug events...' 'Running development mode or debug logs in production is enabled, attaching debug events...',
) )
client.on('packet', ({ client, ...rawPacket }) => client.on('packet', ({ client, ...rawPacket }) =>
logger.debug( logger.debug(
`Packet received from client ${client.id}:`, `Packet received from client ${client.id}:`,
inspectObject(rawPacket) inspectObject(rawPacket),
) ),
) )
client.on('heartbeat', () => client.on('heartbeat', () =>
logger.debug( logger.debug(
'Heartbeat received from client', 'Heartbeat received from client',
client.id client.id,
) ),
) )
} }
} catch (e) { } catch (e) {
if (e instanceof Error) logger.error(e.stack ?? e.message) if (e instanceof Error) logger.error(e.stack ?? e.message)
else logger.error(inspectObject(e)) else logger.error(inspectObject(e))
const client = clientSocketMap.get(connection.socket) const client = clientSocketMap.get(connection.socket)
if (!client) { if (!client) {
logger.error( logger.error(
'Missing client instance when encountering an error. If the instance still exists in memory, it will NOT be removed!' 'Missing client instance when encountering an error. If the instance still exists in memory, it will NOT be removed!',
) )
return connection.socket.terminate() return connection.socket.terminate()
} }
if (client.disconnected === false) if (client.disconnected === false)
client.disconnect(DisconnectReason.ServerError) client.disconnect(DisconnectReason.ServerError)
else client.forceDisconnect() else client.forceDisconnect()
clients.delete(client) clients.delete(client)
logger.debug( logger.debug(
`Client ${client.id} disconnected because of an internal error` `Client ${client.id} disconnected because of an internal error`,
) )
} }
}) })
}) })
// Start the server // Start the server
logger.debug('Starting with these configurations:', inspectObject(config)) logger.debug('Starting with these configurations:', inspectObject(config))
await server.listen({ await server.listen({
host: config.address ?? '0.0.0.0', host: config.address ?? '0.0.0.0',
port: config.port ?? 80, port: config.port ?? 80,
}) })
const addressInfo = server.server.address() const addressInfo = server.server.address()
if (!addressInfo || typeof addressInfo !== 'object') if (!addressInfo || typeof addressInfo !== 'object')
logger.debug('Server started, but cannot determine address information') logger.debug('Server started, but cannot determine address information')
else else
logger.info( logger.info(
'Server started at:', 'Server started at:',
`${addressInfo.address}:${addressInfo.port}` `${addressInfo.address}:${addressInfo.port}`,
) )

View File

@@ -1,9 +1,9 @@
declare global { declare global {
namespace NodeJS { namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
WIT_AI_TOKEN?: string WIT_AI_TOKEN?: string
} }
} }
} }
declare type NodeEnvironment = 'development' | 'production' declare type NodeEnvironment = 'development' | 'production'

View File

@@ -1,31 +1,31 @@
import type { Logger } from './logger.js' import type { Logger } from './logger.js'
export default function checkEnv(logger: Logger) { export default function checkEnv(logger: Logger) {
if (!process.env['NODE_ENV']) if (!process.env['NODE_ENV'])
logger.warn('NODE_ENV not set, defaulting to `development`') logger.warn('NODE_ENV not set, defaulting to `development`')
const environment = (process.env['NODE_ENV'] ?? const environment = (process.env['NODE_ENV'] ??
'development') as NodeEnvironment 'development') as NodeEnvironment
if (!['development', 'production'].includes(environment)) { if (!['development', 'production'].includes(environment)) {
logger.error( logger.error(
'NODE_ENV is neither `development` nor `production`, unable to determine environment' 'NODE_ENV is neither `development` nor `production`, unable to determine environment',
) )
logger.info('Set NODE_ENV to blank to use `development` mode') logger.info('Set NODE_ENV to blank to use `development` mode')
process.exit(1) process.exit(1)
} }
logger.info(`Running in ${environment} mode...`) logger.info(`Running in ${environment} mode...`)
if (environment === 'production' && process.env['IS_USING_DOT_ENV']) { if (environment === 'production' && process.env['IS_USING_DOT_ENV']) {
logger.warn( logger.warn(
'You seem to be using .env files, this is generally not a good idea in production...' 'You seem to be using .env files, this is generally not a good idea in production...',
) )
} }
if (!process.env['WIT_AI_TOKEN']) { if (!process.env['WIT_AI_TOKEN']) {
logger.error('WIT_AI_TOKEN is not defined in the environment variables') logger.error('WIT_AI_TOKEN is not defined in the environment variables')
process.exit(1) process.exit(1)
} }
return environment return environment
} }

View File

@@ -1,40 +1,40 @@
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { resolve as resolvePath } from 'node:path' import { resolve as resolvePath } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
const configPath = resolvePath(process.cwd(), 'config.json') const configPath = resolvePath(process.cwd(), 'config.json')
const userConfig: Partial<Config> = existsSync(configPath) const userConfig: Partial<Config> = existsSync(configPath)
? ( ? (
await import(pathToFileURL(configPath).href, { await import(pathToFileURL(configPath).href, {
assert: { assert: {
type: 'json', type: 'json',
}, },
}) })
).default ).default
: {} : {}
type BaseTypeOf<T> = T extends (infer U)[] type BaseTypeOf<T> = T extends (infer U)[]
? U[] ? U[]
: T extends (...args: unknown[]) => infer U : T extends (...args: unknown[]) => infer U
? (...args: unknown[]) => U ? (...args: unknown[]) => U
: T extends object : T extends object
? { [K in keyof T]: T[K] } ? { [K in keyof T]: T[K] }
: T : T
export type Config = Omit< export type Config = Omit<
BaseTypeOf<typeof import('../../config.json')>, BaseTypeOf<typeof import('../../config.json')>,
'$schema' '$schema'
> >
export const defaultConfig: Config = { export const defaultConfig: Config = {
address: '127.0.0.1', address: '127.0.0.1',
port: 80, port: 80,
ocrConcurrentQueues: 1, ocrConcurrentQueues: 1,
clientHeartbeatInterval: 60000, clientHeartbeatInterval: 60000,
debugLogsInProduction: false, debugLogsInProduction: false,
} }
export default function getConfig() { export default function getConfig() {
return Object.assign(defaultConfig, userConfig) satisfies Config return Object.assign(defaultConfig, userConfig) satisfies Config
} }

View File

@@ -1,3 +1,3 @@
export { default as getConfig } from './getConfig.js' export { default as getConfig } from './getConfig.js'
export { default as checkEnv } from './checkEnv.js' export { default as checkEnv } from './checkEnv.js'
export { default as logger } from './logger.js' export { default as logger } from './logger.js'

View File

@@ -1,25 +1,25 @@
import { Chalk } from 'chalk' import { Chalk } from 'chalk'
const chalk = new Chalk() const chalk = new Chalk()
const logger = { const logger = {
debug: (...args) => console.debug(chalk.gray('DEBUG:', ...args)), debug: (...args) => console.debug(chalk.gray('DEBUG:', ...args)),
info: (...args) => info: (...args) =>
console.info(chalk.bgBlue.whiteBright(' INFO '), ...args), console.info(chalk.bgBlue.whiteBright(' INFO '), ...args),
warn: (...args) => warn: (...args) =>
console.warn( console.warn(
chalk.bgYellow.blackBright.bold(' WARN '), chalk.bgYellow.blackBright.bold(' WARN '),
chalk.yellowBright(...args) chalk.yellowBright(...args),
), ),
error: (...args) => error: (...args) =>
console.error( console.error(
chalk.bgRed.whiteBright.bold(' ERROR '), chalk.bgRed.whiteBright.bold(' ERROR '),
chalk.redBright(...args) chalk.redBright(...args),
), ),
log: console.log, log: console.log,
} satisfies Logger } satisfies Logger
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'
export type LogFunction = (...x: unknown[]) => void export type LogFunction = (...x: unknown[]) => void
export type Logger = Record<LogLevel, LogFunction> export type Logger = Record<LogLevel, LogFunction>
export default logger export default logger

View File

@@ -1,177 +1,177 @@
import { ClientOperation, Packet, ServerOperation } from '@revanced/bot-shared' import { ClientOperation, Packet, ServerOperation } from '@revanced/bot-shared'
import ClientGateway, { ClientGatewayEventHandlers } from './ClientGateway.js' import ClientGateway, { ClientGatewayEventHandlers } from './ClientGateway.js'
/** /**
* The client that connects to the API. * The client that connects to the API.
*/ */
export default class Client { export default class Client {
ready: boolean = false ready = false
gateway: ClientGateway gateway: ClientGateway
#parseId: number = 0 #parseId = 0
constructor(options: ClientOptions) { constructor(options: ClientOptions) {
this.gateway = new ClientGateway({ this.gateway = new ClientGateway({
url: options.api.gatewayUrl, url: options.api.gatewayUrl,
}) })
this.gateway.on('ready', () => { this.gateway.on('ready', () => {
this.ready = true this.ready = true
}) })
} }
/** /**
* Connects to the WebSocket API * Connects to the WebSocket API
* @returns A promise that resolves when the client is ready * @returns A promise that resolves when the client is ready
*/ */
connect() { connect() {
return this.gateway.connect() return this.gateway.connect()
} }
/** /**
* Checks whether the client is ready * Checks whether the client is ready
* @returns Whether the client is ready * @returns Whether the client is ready
*/ */
isReady(): this is ReadiedClient { isReady(): this is ReadiedClient {
return this.ready return this.ready
} }
/** /**
* Requests the API to parse the given text * Requests the API to parse the given text
* @param text The text to parse * @param text The text to parse
* @returns An object containing the ID of the request and the labels * @returns An object containing the ID of the request and the labels
*/ */
async parseText(text: string) { async parseText(text: string) {
this.#throwIfNotReady() this.#throwIfNotReady()
const currentId = (this.#parseId++).toString() const currentId = (this.#parseId++).toString()
this.gateway.send({ this.gateway.send({
op: ClientOperation.ParseText, op: ClientOperation.ParseText,
d: { d: {
text, text,
id: currentId, id: currentId,
}, },
}) })
type CorrectPacket = Packet<ServerOperation.ParsedText> type CorrectPacket = Packet<ServerOperation.ParsedText>
const promise = new Promise<CorrectPacket['d']>((rs, rj) => { const promise = new Promise<CorrectPacket['d']>((rs, rj) => {
const parsedTextListener = (packet: CorrectPacket) => { const parsedTextListener = (packet: CorrectPacket) => {
if (packet.d.id !== currentId) return if (packet.d.id !== currentId) return
this.gateway.off('parsedText', parsedTextListener) this.gateway.off('parsedText', parsedTextListener)
rs(packet.d) rs(packet.d)
} }
const parseTextFailedListener = ( const parseTextFailedListener = (
packet: Packet<ServerOperation.ParseTextFailed> packet: Packet<ServerOperation.ParseTextFailed>,
) => { ) => {
if (packet.d.id !== currentId) return if (packet.d.id !== currentId) return
this.gateway.off('parseTextFailed', parseTextFailedListener) this.gateway.off('parseTextFailed', parseTextFailedListener)
rj() rj()
} }
this.gateway.on('parsedText', parsedTextListener) this.gateway.on('parsedText', parsedTextListener)
this.gateway.on('parseTextFailed', parseTextFailedListener) this.gateway.on('parseTextFailed', parseTextFailedListener)
}) })
return await promise return await promise
} }
/** /**
* Requests the API to parse the given image and return the text * Requests the API to parse the given image and return the text
* @param url The URL of the image * @param url The URL of the image
* @returns An object containing the ID of the request and the parsed text * @returns An object containing the ID of the request and the parsed text
*/ */
async parseImage(url: string) { async parseImage(url: string) {
this.#throwIfNotReady() this.#throwIfNotReady()
const currentId = (this.#parseId++).toString() const currentId = (this.#parseId++).toString()
this.gateway.send({ this.gateway.send({
op: ClientOperation.ParseImage, op: ClientOperation.ParseImage,
d: { d: {
image_url: url, image_url: url,
id: currentId, id: currentId,
}, },
}) })
type CorrectPacket = Packet<ServerOperation.ParsedImage> type CorrectPacket = Packet<ServerOperation.ParsedImage>
const promise = new Promise<CorrectPacket['d']>((rs, rj) => { const promise = new Promise<CorrectPacket['d']>((rs, rj) => {
const parsedImageListener = (packet: CorrectPacket) => { const parsedImageListener = (packet: CorrectPacket) => {
if (packet.d.id !== currentId) return if (packet.d.id !== currentId) return
this.gateway.off('parsedImage', parsedImageListener) this.gateway.off('parsedImage', parsedImageListener)
rs(packet.d) rs(packet.d)
} }
const parseImageFailedListener = ( const parseImageFailedListener = (
packet: Packet<ServerOperation.ParseImageFailed> packet: Packet<ServerOperation.ParseImageFailed>,
) => { ) => {
if (packet.d.id !== currentId) return if (packet.d.id !== currentId) return
this.gateway.off('parseImageFailed', parseImageFailedListener) this.gateway.off('parseImageFailed', parseImageFailedListener)
rj() rj()
} }
this.gateway.on('parsedImage', parsedImageListener) this.gateway.on('parsedImage', parsedImageListener)
this.gateway.on('parseImageFailed', parseImageFailedListener) this.gateway.on('parseImageFailed', parseImageFailedListener)
}) })
return await promise return await promise
} }
/** /**
* Adds an event listener * Adds an event listener
* @param name The event name to listen for * @param name The event name to listen for
* @param handler The event handler * @param handler The event handler
* @returns The event handler function * @returns The event handler function
*/ */
on<TOpName extends keyof ClientGatewayEventHandlers>( on<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[TOpName] handler: ClientGatewayEventHandlers[TOpName],
) { ) {
this.gateway.on(name, handler) this.gateway.on(name, handler)
return handler return handler
} }
/** /**
* Removes an event listener * Removes an event listener
* @param name The event name to remove a listener from * @param name The event name to remove a listener from
* @param handler The event handler to remove * @param handler The event handler to remove
* @returns The removed event handler function * @returns The removed event handler function
*/ */
off<TOpName extends keyof ClientGatewayEventHandlers>( off<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[TOpName] handler: ClientGatewayEventHandlers[TOpName],
) { ) {
this.gateway.off(name, handler) this.gateway.off(name, handler)
return handler return handler
} }
/** /**
* Adds an event listener that will only be called once * Adds an event listener that will only be called once
* @param name The event name to listen for * @param name The event name to listen for
* @param handler The event handler * @param handler The event handler
* @returns The event handler function * @returns The event handler function
*/ */
once<TOpName extends keyof ClientGatewayEventHandlers>( once<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[TOpName] handler: ClientGatewayEventHandlers[TOpName],
) { ) {
this.gateway.once(name, handler) this.gateway.once(name, handler)
return handler return handler
} }
#throwIfNotReady() { #throwIfNotReady() {
if (!this.isReady()) throw new Error('Client is not ready') if (!this.isReady()) throw new Error('Client is not ready')
} }
} }
export type ReadiedClient = Client & { ready: true } export type ReadiedClient = Client & { ready: true }
export interface ClientOptions { export interface ClientOptions {
api: ClientApiOptions api: ClientApiOptions
} }
export interface ClientApiOptions { export interface ClientApiOptions {
gatewayUrl: string gatewayUrl: string
} }

View File

@@ -1,234 +1,235 @@
import { type RawData, WebSocket } from 'ws' import { EventEmitter } from 'events'
import type TypedEmitter from 'typed-emitter' import {
import { ClientOperation,
ClientOperation, DisconnectReason,
DisconnectReason, Packet,
Packet, ServerOperation,
ServerOperation, deserializePacket,
deserializePacket, isServerPacket,
isServerPacket, serializePacket,
serializePacket, uncapitalize,
uncapitalize, } from '@revanced/bot-shared'
} from '@revanced/bot-shared' import type TypedEmitter from 'typed-emitter'
import { EventEmitter } from 'events' import { type RawData, WebSocket } from 'ws'
/** /**
* The class that handles the WebSocket connection to the server. * 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. * 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 default class ClientGateway {
readonly url: string readonly url: string
ready: boolean = false ready = false
disconnected: boolean | DisconnectReason = DisconnectReason.NeverConnected 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! #hbTimeout: NodeJS.Timeout = null!
#socket: WebSocket = null! #socket: WebSocket = null!
#emitter = new EventEmitter() as TypedEmitter<ClientGatewayEventHandlers> #emitter = new EventEmitter() as TypedEmitter<ClientGatewayEventHandlers>
constructor(options: ClientGatewayOptions) { constructor(options: ClientGatewayOptions) {
this.url = options.url this.url = options.url
} }
/** /**
* Connects to the WebSocket API * Connects to the WebSocket API
* @returns A promise that resolves when the client is ready * @returns A promise that resolves when the client is ready
*/ */
connect() { connect() {
return new Promise<void>((rs, rj) => { return new Promise<void>((rs, rj) => {
try { try {
this.#socket = new WebSocket(this.url) this.#socket = new WebSocket(this.url)
this.#socket.on('open', () => { this.#socket.on('open', () => {
this.disconnected = false this.disconnected = false
rs() rs()
}) })
this.#socket.on('close', () => this.#socket.on('close', () =>
this.#handleDisconnect(DisconnectReason.Generic) this.#handleDisconnect(DisconnectReason.Generic),
) )
this.#listen() this.#listen()
this.ready = true this.ready = true
this.#emitter.emit('ready') this.#emitter.emit('ready')
} catch (e) { } catch (e) {
rj(e) rj(e)
} }
}) })
} }
/** /**
* Adds an event listener * Adds an event listener
* @param name The event name to listen for * @param name The event name to listen for
* @param handler The event handler * @param handler The event handler
* @returns The event handler function * @returns The event handler function
*/ */
on<TOpName extends keyof ClientGatewayEventHandlers>( on<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[typeof name] handler: ClientGatewayEventHandlers[typeof name],
) { ) {
this.#emitter.on(name, handler) this.#emitter.on(name, handler)
} }
/** /**
* Removes an event listener * Removes an event listener
* @param name The event name to remove a listener from * @param name The event name to remove a listener from
* @param handler The event handler to remove * @param handler The event handler to remove
* @returns The removed event handler function * @returns The removed event handler function
*/ */
off<TOpName extends keyof ClientGatewayEventHandlers>( off<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[typeof name] handler: ClientGatewayEventHandlers[typeof name],
) { ) {
this.#emitter.off(name, handler) this.#emitter.off(name, handler)
} }
/** /**
* Adds an event listener that will only be called once * Adds an event listener that will only be called once
* @param name The event name to listen for * @param name The event name to listen for
* @param handler The event handler * @param handler The event handler
* @returns The event handler function * @returns The event handler function
*/ */
once<TOpName extends keyof ClientGatewayEventHandlers>( once<TOpName extends keyof ClientGatewayEventHandlers>(
name: TOpName, name: TOpName,
handler: ClientGatewayEventHandlers[typeof name] handler: ClientGatewayEventHandlers[typeof name],
) { ) {
this.#emitter.once(name, handler) this.#emitter.once(name, handler)
} }
/** /**
* Sends a packet to the server * Sends a packet to the server
* @param packet The packet to send * @param packet The packet to send
* @returns A promise that resolves when the packet has been sent * @returns A promise that resolves when the packet has been sent
*/ */
send<TOp extends ClientOperation>(packet: Packet<TOp>) { send<TOp extends ClientOperation>(packet: Packet<TOp>) {
this.#throwIfDisconnected( this.#throwIfDisconnected(
'Cannot send a packet when already disconnected from the server' 'Cannot send a packet when already disconnected from the server',
) )
return new Promise<void>((resolve, reject) => return new Promise<void>((resolve, reject) =>
this.#socket.send(serializePacket(packet), err => this.#socket.send(serializePacket(packet), err =>
err ? reject(err) : resolve() err ? reject(err) : resolve(),
) ),
) )
} }
/** /**
* Disconnects from the WebSocket API * Disconnects from the WebSocket API
*/ */
disconnect() { disconnect() {
this.#throwIfDisconnected( this.#throwIfDisconnected(
'Cannot disconnect when already disconnected from the server' 'Cannot disconnect when already disconnected from the server',
) )
this.#handleDisconnect(DisconnectReason.Generic) this.#handleDisconnect(DisconnectReason.Generic)
} }
/** /**
* Checks whether the client is ready * Checks whether the client is ready
* @returns Whether the client is ready * @returns Whether the client is ready
*/ */
isReady(): this is ReadiedClientGateway { isReady(): this is ReadiedClientGateway {
return this.ready return this.ready
} }
#listen() { #listen() {
this.#socket.on('message', data => { this.#socket.on('message', data => {
const packet = deserializePacket(this._toBuffer(data)) const packet = deserializePacket(this._toBuffer(data))
// TODO: maybe log this? // TODO: maybe log this?
// Just ignore the invalid packet, we don't have to disconnect // Just ignore the invalid packet, we don't have to disconnect
if (!isServerPacket(packet)) return if (!isServerPacket(packet)) return
this.#emitter.emit('packet', packet) this.#emitter.emit('packet', packet)
switch (packet.op) { switch (packet.op) {
case ServerOperation.Hello: case ServerOperation.Hello: {
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const data = Object.freeze( const data = Object.freeze(
(packet as Packet<ServerOperation.Hello>).d (packet as Packet<ServerOperation.Hello>).d,
) )
this.config = data this.config = data
this.#emitter.emit('hello', data) this.#emitter.emit('hello', data)
this.#startHeartbeating() this.#startHeartbeating()
break break
case ServerOperation.Disconnect: }
return this.#handleDisconnect( case ServerOperation.Disconnect:
(packet as Packet<ServerOperation.Disconnect>).d.reason return this.#handleDisconnect(
) (packet as Packet<ServerOperation.Disconnect>).d.reason,
default: )
return this.#emitter.emit( default:
uncapitalize( return this.#emitter.emit(
ServerOperation[ uncapitalize(
packet.op ServerOperation[
] as ClientGatewayServerEventName packet.op
), ] as ClientGatewayServerEventName,
// @ts-expect-error TypeScript doesn't know that the lines above negate the type enough ),
packet // @ts-expect-error TypeScript doesn't know that the lines above negate the type enough
) packet,
} )
}) }
} })
}
#throwIfDisconnected(errorMessage: string) {
if (this.disconnected !== false) throw new Error(errorMessage) #throwIfDisconnected(errorMessage: string) {
if (this.#socket.readyState !== this.#socket.OPEN) if (this.disconnected !== false) throw new Error(errorMessage)
throw new Error(errorMessage) if (this.#socket.readyState !== this.#socket.OPEN)
} throw new Error(errorMessage)
}
#handleDisconnect(reason: DisconnectReason) {
clearTimeout(this.#hbTimeout) #handleDisconnect(reason: DisconnectReason) {
this.disconnected = reason clearTimeout(this.#hbTimeout)
this.#socket.close() this.disconnected = reason
this.#socket.close()
this.#emitter.emit('disconnect', reason)
} this.#emitter.emit('disconnect', reason)
}
#startHeartbeating() {
this.on('heartbeatAck', packet => { #startHeartbeating() {
this.#hbTimeout = setTimeout(() => { this.on('heartbeatAck', packet => {
this.send({ this.#hbTimeout = setTimeout(() => {
op: ClientOperation.Heartbeat, this.send({
d: null, op: ClientOperation.Heartbeat,
}) d: null,
}, packet.d.nextHeartbeat - Date.now()) })
}) }, packet.d.nextHeartbeat - Date.now())
})
// Immediately send a heartbeat so we can get when to send the next one
this.send({ // Immediately send a heartbeat so we can get when to send the next one
op: ClientOperation.Heartbeat, this.send({
d: null, op: ClientOperation.Heartbeat,
}) d: null,
} })
}
protected _toBuffer(data: RawData) {
if (data instanceof Buffer) return data protected _toBuffer(data: RawData) {
else if (data instanceof ArrayBuffer) return Buffer.from(data) if (data instanceof Buffer) return data
else return Buffer.concat(data) else if (data instanceof ArrayBuffer) return Buffer.from(data)
} else return Buffer.concat(data)
} }
}
export interface ClientGatewayOptions {
/** export interface ClientGatewayOptions {
* The gateway URL to connect to /**
*/ * The gateway URL to connect to
url: string */
} url: string
}
export type ClientGatewayServerEventName = keyof typeof ServerOperation
export type ClientGatewayServerEventName = keyof typeof ServerOperation
export type ClientGatewayEventHandlers = {
[K in Uncapitalize<ClientGatewayServerEventName>]: ( export type ClientGatewayEventHandlers = {
packet: Packet<(typeof ServerOperation)[Capitalize<K>]> [K in Uncapitalize<ClientGatewayServerEventName>]: (
) => Promise<void> | void packet: Packet<typeof ServerOperation[Capitalize<K>]>,
} & { ) => Promise<void> | void
hello: ( } & {
config: NonNullable<ClientGateway['config']> hello: (
) => Promise<void> | void config: NonNullable<ClientGateway['config']>,
ready: () => Promise<void> | void ) => Promise<void> | void
packet: (packet: Packet<ServerOperation>) => Promise<void> | void ready: () => Promise<void> | void
disconnect: (reason: DisconnectReason) => Promise<void> | void packet: (packet: Packet<ServerOperation>) => Promise<void> | void
} disconnect: (reason: DisconnectReason) => Promise<void> | void
}
export type ReadiedClientGateway = RequiredProperty<
InstanceType<typeof ClientGateway> export type ReadiedClientGateway = RequiredProperty<
> InstanceType<typeof ClientGateway>
>

View File

@@ -1,4 +1,4 @@
export { default as Client } from './Client.js' export { default as Client } from './Client.js'
export * from './Client.js' export * from './Client.js'
export { default as ClientGateway } from './ClientGateway.js' export { default as ClientGateway } from './ClientGateway.js'
export * from './ClientGateway.js' export * from './ClientGateway.js'

View File

@@ -1 +1 @@
export * from './classes/index.js' export * from './classes/index.js'

View File

@@ -1 +1 @@
type RequiredProperty<T> = { [P in keyof T]: Required<NonNullable<T[P]>> } type RequiredProperty<T> = { [P in keyof T]: Required<NonNullable<T[P]>> }

View File

@@ -1,27 +1,27 @@
/** /**
* Disconnect reasons for clients * Disconnect reasons for clients
*/ */
enum DisconnectReason { enum DisconnectReason {
/** /**
* Unknown reason * Unknown reason
*/ */
Generic = 1, Generic = 1,
/** /**
* The client did not respond in time * The client did not respond in time
*/ */
TimedOut, TimedOut = 2,
/** /**
* The client sent an invalid packet (unserializable or invalid JSON) * The client sent an invalid packet (unserializable or invalid JSON)
*/ */
InvalidPacket, InvalidPacket = 3,
/** /**
* The server has encountered an internal error * The server has encountered an internal error
*/ */
ServerError, ServerError = 4,
/** /**
* The client had never connected to the server (**CLIENT-ONLY**) * The client had never connected to the server (**CLIENT-ONLY**)
*/ */
NeverConnected, NeverConnected = 5,
} }
export default DisconnectReason export default DisconnectReason

View File

@@ -1,15 +1,15 @@
import DisconnectReason from './DisconnectReason.js' import DisconnectReason from './DisconnectReason.js'
/** /**
* Humanized disconnect reasons for logs * Humanized disconnect reasons for logs
*/ */
const HumanizedDisconnectReason = { const HumanizedDisconnectReason = {
[DisconnectReason.InvalidPacket]: 'has sent invalid packet', [DisconnectReason.InvalidPacket]: 'has sent invalid packet',
[DisconnectReason.Generic]: 'has been disconnected for unknown reasons', [DisconnectReason.Generic]: 'has been disconnected for unknown reasons',
[DisconnectReason.TimedOut]: 'has timed out', [DisconnectReason.TimedOut]: 'has timed out',
[DisconnectReason.ServerError]: [DisconnectReason.ServerError]:
'has been disconnected due to an internal server error', 'has been disconnected due to an internal server error',
[DisconnectReason.NeverConnected]: 'had never connected to the server', [DisconnectReason.NeverConnected]: 'had never connected to the server',
} as const satisfies Record<DisconnectReason, string> } as const satisfies Record<DisconnectReason, string>
export default HumanizedDisconnectReason export default HumanizedDisconnectReason

View File

@@ -1,57 +1,57 @@
/** /**
* Client operation codes for the gateway * Client operation codes for the gateway
*/ */
export enum ClientOperation { export enum ClientOperation {
/** /**
* Client's heartbeat (to check if the connection is dead or not) * Client's heartbeat (to check if the connection is dead or not)
*/ */
Heartbeat = 100, Heartbeat = 100,
/** /**
* Client's request to parse text * Client's request to parse text
*/ */
ParseText = 110, ParseText = 110,
/** /**
* Client's request to parse image * Client's request to parse image
*/ */
ParseImage, ParseImage = 111,
} }
/** /**
* Server operation codes for the gateway * Server operation codes for the gateway
*/ */
export enum ServerOperation { export enum ServerOperation {
/** /**
* Server's acknowledgement of a client's heartbeat * Server's acknowledgement of a client's heartbeat
*/ */
HeartbeatAck = 1, HeartbeatAck = 1,
/** /**
* Server's initial response to a client's connection * Server's initial response to a client's connection
*/ */
Hello, Hello = 2,
/** /**
* Server's response to client's request to parse text * Server's response to client's request to parse text
*/ */
ParsedText = 10, ParsedText = 10,
/** /**
* Server's response to client's request to parse image * Server's response to client's request to parse image
*/ */
ParsedImage, ParsedImage = 11,
/** /**
* Server's failure response to client's request to parse text * Server's failure response to client's request to parse text
*/ */
ParseTextFailed, ParseTextFailed = 12,
/** /**
* Server's failure response to client's request to parse image * Server's failure response to client's request to parse image
*/ */
ParseImageFailed, ParseImageFailed = 13,
/** /**
* Server's disconnect message * Server's disconnect message
*/ */
Disconnect = 20, Disconnect = 20,
} }
export const Operation = { ...ClientOperation, ...ServerOperation } as const export const Operation = { ...ClientOperation, ...ServerOperation } as const
export type Operation = ClientOperation | ServerOperation export type Operation = ClientOperation | ServerOperation

View File

@@ -1,3 +1,3 @@
export { default as DisconnectReason } from './DisconnectReason.js' export { default as DisconnectReason } from './DisconnectReason.js'
export { default as HumanizedDisconnectReason } from './HumanizedDisconnectReason.js' export { default as HumanizedDisconnectReason } from './HumanizedDisconnectReason.js'
export * from './Operation.js' export * from './Operation.js'

View File

@@ -1,3 +1,3 @@
export * from './constants/index.js' export * from './constants/index.js'
export * from './schemas/index.js' export * from './schemas/index.js'
export * from './utils/index.js' export * from './utils/index.js'

View File

@@ -1,101 +1,101 @@
import DisconnectReason from '../constants/DisconnectReason.js' import {
import { url,
ClientOperation, AnySchema,
Operation, NullSchema,
ServerOperation, ObjectSchema,
} from '../constants/Operation.js' Output,
import { array,
object, enum_,
enum_, null_,
special, number,
ObjectSchema, object,
number, parse,
string, special,
Output, string,
AnySchema, // merge
null_, } from 'valibot'
NullSchema, import DisconnectReason from '../constants/DisconnectReason.js'
array, import {
url, ClientOperation,
parse, Operation,
// merge ServerOperation,
} from 'valibot' } from '../constants/Operation.js'
/** /**
* Schema to validate packets * Schema to validate packets
*/ */
export const PacketSchema = special<Packet>(input => { export const PacketSchema = special<Packet>(input => {
if ( if (
typeof input === 'object' && typeof input === 'object' &&
input && input &&
'op' in input && 'op' in input &&
typeof input.op === 'number' && typeof input.op === 'number' &&
input.op in Operation && input.op in Operation &&
'd' in input && 'd' in input &&
typeof input.d === 'object' typeof input.d === 'object'
) { ) {
try { try {
parse(PacketDataSchemas[input.op as Operation], input.d) parse(PacketDataSchemas[input.op as Operation], input.d)
return true return true
} catch { } catch {
return false return false
} }
} }
return false return false
}, 'Invalid packet data') }, 'Invalid packet data')
/** /**
* Schema to validate packet data for each possible operations * Schema to validate packet data for each possible operations
*/ */
export const PacketDataSchemas = { export const PacketDataSchemas = {
[ServerOperation.Hello]: object({ [ServerOperation.Hello]: object({
heartbeatInterval: number(), heartbeatInterval: number(),
}), }),
[ServerOperation.HeartbeatAck]: object({ [ServerOperation.HeartbeatAck]: object({
nextHeartbeat: number(), nextHeartbeat: number(),
}), }),
[ServerOperation.ParsedText]: object({ [ServerOperation.ParsedText]: object({
id: string(), id: string(),
labels: array( labels: array(
object({ object({
name: string(), name: string(),
confidence: special<number>( confidence: special<number>(
input => input =>
typeof input === 'number' && input >= 0 && input <= 1 typeof input === 'number' && input >= 0 && input <= 1,
), ),
}) }),
), ),
}), }),
[ServerOperation.ParsedImage]: object({ [ServerOperation.ParsedImage]: object({
id: string(), id: string(),
text: string(), text: string(),
}), }),
[ServerOperation.ParseTextFailed]: object({ [ServerOperation.ParseTextFailed]: object({
id: string(), id: string(),
}), }),
[ServerOperation.ParseImageFailed]: object({ [ServerOperation.ParseImageFailed]: object({
id: string(), id: string(),
}), }),
[ServerOperation.Disconnect]: object({ [ServerOperation.Disconnect]: object({
reason: enum_(DisconnectReason), reason: enum_(DisconnectReason),
}), }),
[ClientOperation.Heartbeat]: null_(), [ClientOperation.Heartbeat]: null_(),
[ClientOperation.ParseText]: object({ [ClientOperation.ParseText]: object({
id: string(), id: string(),
text: string(), text: string(),
}), }),
[ClientOperation.ParseImage]: object({ [ClientOperation.ParseImage]: object({
id: string(), id: string(),
image_url: string([url()]), image_url: string([url()]),
}), }),
} as const satisfies Record< } as const satisfies Record<
Operation, Operation,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // biome-ignore lint/suspicious/noExplicitAny: This is a schema, it's not possible to type it
ObjectSchema<any> | AnySchema | NullSchema ObjectSchema<any> | AnySchema | NullSchema
> >
export type Packet<TOp extends Operation = Operation> = { export type Packet<TOp extends Operation = Operation> = {
op: TOp op: TOp
d: Output<(typeof PacketDataSchemas)[TOp]> d: Output<typeof PacketDataSchemas[TOp]>
} }

View File

@@ -1 +1 @@
export * from './Packet.js' export * from './Packet.js'

View File

@@ -1,41 +1,41 @@
import { Packet } from '../schemas/Packet.js' import {
import { ClientOperation,
ClientOperation, Operation,
Operation, ServerOperation,
ServerOperation, } from '../constants/Operation.js'
} from '../constants/Operation.js' import { Packet } from '../schemas/Packet.js'
/** /**
* Checks whether a packet is trying to do the given operation * Checks whether a packet is trying to do the given operation
* @param op Operation code to check * @param op Operation code to check
* @param packet A packet * @param packet A packet
* @returns Whether this packet is trying to do the operation given * @returns Whether this packet is trying to do the operation given
*/ */
export function packetMatchesOperation<TOp extends Operation>( export function packetMatchesOperation<TOp extends Operation>(
op: TOp, op: TOp,
packet: Packet packet: Packet,
): packet is Packet<TOp> { ): packet is Packet<TOp> {
return packet.op === op return packet.op === op
} }
/** /**
* Checks whether this packet is a client packet **(this does NOT validate the data)** * Checks whether this packet is a client packet **(this does NOT validate the data)**
* @param packet A packet * @param packet A packet
* @returns Whether this packet is a client packet * @returns Whether this packet is a client packet
*/ */
export function isClientPacket( export function isClientPacket(
packet: Packet packet: Packet,
): packet is Packet<ClientOperation> { ): packet is Packet<ClientOperation> {
return packet.op in ClientOperation return packet.op in ClientOperation
} }
/** /**
* Checks whether this packet is a server packet **(this does NOT validate the data)** * Checks whether this packet is a server packet **(this does NOT validate the data)**
* @param packet A packet * @param packet A packet
* @returns Whether this packet is a server packet * @returns Whether this packet is a server packet
*/ */
export function isServerPacket( export function isServerPacket(
packet: Packet packet: Packet,
): packet is Packet<ServerOperation> { ): packet is Packet<ServerOperation> {
return packet.op in ServerOperation return packet.op in ServerOperation
} }

View File

@@ -1,3 +1,3 @@
export * from './guard.js' export * from './guard.js'
export * from './serialization.js' export * from './serialization.js'
export * from './string.js' export * from './string.js'

View File

@@ -1,23 +1,23 @@
import * as BSON from 'bson' import * as BSON from 'bson'
import { Packet, PacketSchema } from '../schemas/index.js' import { parse } from 'valibot'
import { Operation } from '../constants/index.js' import { Operation } from '../constants/index.js'
import { parse } from 'valibot' import { Packet, PacketSchema } from '../schemas/index.js'
/** /**
* Compresses a packet into a buffer * Compresses a packet into a buffer
* @param packet The packet to compress * @param packet The packet to compress
* @returns A buffer of the compressed packet * @returns A buffer of the compressed packet
*/ */
export function serializePacket<TOp extends Operation>(packet: Packet<TOp>) { export function serializePacket<TOp extends Operation>(packet: Packet<TOp>) {
return BSON.serialize(packet) return BSON.serialize(packet)
} }
/** /**
* Decompresses a buffer into a packet * Decompresses a buffer into a packet
* @param buffer The buffer to decompress * @param buffer The buffer to decompress
* @returns A packet * @returns A packet
*/ */
export function deserializePacket(buffer: Buffer) { export function deserializePacket(buffer: Buffer) {
const data = BSON.deserialize(buffer) const data = BSON.deserialize(buffer)
return parse(PacketSchema, data) as Packet return parse(PacketSchema, data) as Packet
} }

View File

@@ -1,8 +1,8 @@
/** /**
* Uncapitalizes the first letter of a string * Uncapitalizes the first letter of a string
* @param str The string to uncapitalize * @param str The string to uncapitalize
* @returns The uncapitalized string * @returns The uncapitalized string
*/ */
export function uncapitalize<T extends string>(str: T): Uncapitalize<T> { export function uncapitalize<T extends string>(str: T): Uncapitalize<T> {
return (str.charAt(0).toLowerCase() + str.slice(1)) as Uncapitalize<T> return (str.charAt(0).toLowerCase() + str.slice(1)) as Uncapitalize<T>
} }

View File

@@ -1,3 +1,3 @@
{ {
"extends": "./tsconfig.base.json" "extends": "./tsconfig.base.json"
} }

View File

@@ -1,15 +1,15 @@
{ {
"extends": "./tsconfig.base.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"declaration": true, "declaration": true,
"declarationMap": true "declarationMap": true
}, },
"references": [ "references": [
{ {
"path": "./packages/shared" "path": "./packages/shared"
}, },
{ {
"path": "./packages/api" "path": "./packages/api"
} }
] ]
} }

View File

@@ -1,14 +1,14 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["dist/**"], "outputs": ["dist/**"],
"outputMode": "errors-only" "outputMode": "errors-only"
}, },
"watch": { "watch": {
"dependsOn": ["^watch"], "dependsOn": ["^watch"],
"outputMode": "errors-only" "outputMode": "errors-only"
} }
} }
} }