From f2d85c32a495d46df6818c93b5270d87f4f3d366 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 28 Nov 2023 22:03:41 +0700 Subject: [PATCH] chore: apply code fixes with biome --- .commitlintrc.js | 6 +- apis/websocket/config.json | 18 +- apis/websocket/config.schema.json | 62 +-- apis/websocket/src/classes/Client.ts | 436 ++++++++-------- apis/websocket/src/events/index.ts | 40 +- apis/websocket/src/events/parseImage.ts | 126 ++--- apis/websocket/src/events/parseText.ts | 86 ++-- apis/websocket/src/index.ts | 314 ++++++------ apis/websocket/src/types.d.ts | 18 +- apis/websocket/src/utils/checkEnv.ts | 62 +-- apis/websocket/src/utils/getConfig.ts | 80 +-- apis/websocket/src/utils/index.ts | 6 +- apis/websocket/src/utils/logger.ts | 50 +- packages/api/src/classes/Client.ts | 354 ++++++------- packages/api/src/classes/ClientGateway.ts | 469 +++++++++--------- packages/api/src/classes/index.ts | 8 +- packages/api/src/index.ts | 2 +- packages/api/utility-types.d.ts | 2 +- .../shared/src/constants/DisconnectReason.ts | 54 +- .../constants/HumanizedDisconnectReason.ts | 30 +- packages/shared/src/constants/Operation.ts | 114 ++--- packages/shared/src/constants/index.ts | 6 +- packages/shared/src/index.ts | 6 +- packages/shared/src/schemas/Packet.ts | 202 ++++---- packages/shared/src/schemas/index.ts | 2 +- packages/shared/src/utils/guard.ts | 82 +-- packages/shared/src/utils/index.ts | 6 +- packages/shared/src/utils/serialization.ts | 46 +- packages/shared/src/utils/string.ts | 16 +- tsconfig.apis.json | 6 +- tsconfig.packages.json | 30 +- turbo.json | 28 +- 32 files changed, 1384 insertions(+), 1383 deletions(-) diff --git a/.commitlintrc.js b/.commitlintrc.js index da13e76..3e16e7f 100755 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -1,3 +1,3 @@ -module.exports = { - extends: ['@commitlint/config-conventional'], -} +module.exports = { + extends: ['@commitlint/config-conventional'], +} diff --git a/apis/websocket/config.json b/apis/websocket/config.json index 06dcc28..8d54b6c 100755 --- a/apis/websocket/config.json +++ b/apis/websocket/config.json @@ -1,9 +1,9 @@ -{ - "$schema": "./config.schema.json", - - "address": "127.0.0.1", - "port": 3000, - "ocrConcurrentQueues": 1, - "clientHeartbeatInterval": 5000, - "debugLogsInProduction": false -} +{ + "$schema": "./config.schema.json", + + "address": "127.0.0.1", + "port": 3000, + "ocrConcurrentQueues": 1, + "clientHeartbeatInterval": 5000, + "debugLogsInProduction": false +} diff --git a/apis/websocket/config.schema.json b/apis/websocket/config.schema.json index adc77ed..30fc6f7 100755 --- a/apis/websocket/config.schema.json +++ b/apis/websocket/config.schema.json @@ -1,31 +1,31 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "properties": { - "address": { - "description": "Address to listen on", - "type": "string", - "default": "127.0.0.1" - }, - "port": { - "description": "Port to listen on", - "type": "integer", - "default": 80 - }, - "ocrConcurrentQueues": { - "description": "Number of concurrent queues for OCR", - "type": "integer", - "default": 1 - }, - "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", - "type": "integer", - "default": 60000 - }, - "debugLogsInProduction": { - "description": "Whether to print debug logs in production", - "type": "boolean", - "default": false - } - } -} +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "address": { + "description": "Address to listen on", + "type": "string", + "default": "127.0.0.1" + }, + "port": { + "description": "Port to listen on", + "type": "integer", + "default": 80 + }, + "ocrConcurrentQueues": { + "description": "Number of concurrent queues for OCR", + "type": "integer", + "default": 1 + }, + "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", + "type": "integer", + "default": 60000 + }, + "debugLogsInProduction": { + "description": "Whether to print debug logs in production", + "type": "boolean", + "default": false + } + } +} diff --git a/apis/websocket/src/classes/Client.ts b/apis/websocket/src/classes/Client.ts index 09bf3d3..dcfdbc1 100755 --- a/apis/websocket/src/classes/Client.ts +++ b/apis/websocket/src/classes/Client.ts @@ -1,218 +1,218 @@ -import { - ClientOperation, - DisconnectReason, - Packet, - ServerOperation, - deserializePacket, - isClientPacket, - serializePacket, - uncapitalize, -} from '@revanced/bot-shared' -import { EventEmitter } from 'node:events' - -import type TypedEmitter from 'typed-emitter' -import type { RawData, WebSocket } from 'ws' - -export default class Client { - id: string - disconnected: DisconnectReason | false = false - ready: boolean = false - - lastHeartbeat: number = null! - heartbeatInterval: number - - #hbTimeout: NodeJS.Timeout = null! - #emitter = new EventEmitter() as TypedEmitter - #socket: WebSocket - - constructor(options: ClientOptions) { - this.#socket = options.socket - this.heartbeatInterval = options.heartbeatInterval ?? 60000 - this.id = options.id - - this.#socket.on('error', () => this.forceDisconnect()) - this.#socket.on('close', () => this.forceDisconnect()) - this.#socket.on('unexpected-response', () => this.forceDisconnect()) - - this.send({ - op: ServerOperation.Hello, - d: { - heartbeatInterval: this.heartbeatInterval, - }, - }) - .then(() => { - this.#listen() - this.#listenHeartbeat() - this.ready = true - this.#emitter.emit('ready') - }) - .catch(() => { - if (this.disconnected === false) - this.disconnect(DisconnectReason.ServerError) - else this.forceDisconnect(DisconnectReason.ServerError) - }) - } - - on( - name: TOpName, - handler: ClientEventHandlers[typeof name] - ) { - this.#emitter.on(name, handler) - } - - once( - name: TOpName, - handler: ClientEventHandlers[typeof name] - ) { - this.#emitter.once(name, handler) - } - - off( - name: TOpName, - handler: ClientEventHandlers[typeof name] - ) { - this.#emitter.off(name, handler) - } - - send(packet: Packet) { - return new Promise((resolve, reject) => { - try { - this.#throwIfDisconnected( - 'Cannot send packet to client that has already disconnected' - ) - - this.#socket.send(serializePacket(packet), err => - err ? reject(err) : resolve() - ) - } catch (e) { - reject(e) - } - }) - } - - async disconnect(reason: DisconnectReason = DisconnectReason.Generic) { - this.#throwIfDisconnected( - 'Cannot disconnect client that has already disconnected' - ) - - try { - await this.send({ op: ServerOperation.Disconnect, d: { reason } }) - } catch (err) { - throw new Error( - `Cannot send disconnect reason to client ${this.id}: ${err}` - ) - } finally { - this.forceDisconnect(reason) - } - } - - forceDisconnect(reason: DisconnectReason = DisconnectReason.Generic) { - if (this.disconnected !== false) return - - if (this.#hbTimeout) clearTimeout(this.#hbTimeout) - this.#socket.terminate() - - this.ready = false - this.disconnected = reason - - this.#emitter.emit('disconnect', reason) - } - - #throwIfDisconnected(errorMessage: string) { - if (this.disconnected !== false) throw new Error(errorMessage) - - if (this.#socket.readyState !== this.#socket.OPEN) { - this.forceDisconnect(DisconnectReason.Generic) - throw new Error(errorMessage) - } - } - - #listen() { - this.#socket.on('message', data => { - try { - const rawPacket = deserializePacket(this._toBuffer(data)) - if (!isClientPacket(rawPacket)) throw null - - const packet: ClientPacketObject = { - ...rawPacket, - client: this, - } - - this.#emitter.emit('packet', packet) - this.#emitter.emit( - uncapitalize(ClientOperation[packet.op] as ClientEventName), - // @ts-expect-error TypeScript doesn't know that the above line will negate the type enough - packet - ) - } catch (e) { - // TODO: add error fields to sent packet so we can log what went wrong - this.disconnect(DisconnectReason.InvalidPacket) - } - }) - } - - #listenHeartbeat() { - this.lastHeartbeat = Date.now() - this.#startHeartbeatTimeout() - - this.on('heartbeat', () => { - this.lastHeartbeat = Date.now() - this.#hbTimeout.refresh() - - this.send({ - op: ServerOperation.HeartbeatAck, - d: { - nextHeartbeat: this.lastHeartbeat + this.heartbeatInterval, - }, - }).catch(() => {}) - }) - } - - #startHeartbeatTimeout() { - this.#hbTimeout = setTimeout(() => { - if (Date.now() - this.lastHeartbeat > 0) { - // TODO: put into config - // 5000 is extra time to account for latency - const interval = setTimeout( - () => this.disconnect(DisconnectReason.TimedOut), - 5000 - ) - - this.once('heartbeat', () => clearTimeout(interval)) - // This should never happen but it did in my testing so I'm adding this just in case - this.once('disconnect', () => clearTimeout(interval)) - // Technically we don't have to do this, but JUST IN CASE! - } else this.#hbTimeout.refresh() - }, this.heartbeatInterval) - } - - protected _toBuffer(data: RawData) { - if (data instanceof Buffer) return data - else if (data instanceof ArrayBuffer) return Buffer.from(data) - else return Buffer.concat(data) - } -} - -export interface ClientOptions { - id: string - socket: WebSocket - heartbeatInterval?: number -} - -export type ClientPacketObject = Packet & { - client: Client -} - -export type ClientEventName = keyof typeof ClientOperation - -export type ClientEventHandlers = { - [K in Uncapitalize]: ( - packet: ClientPacketObject<(typeof ClientOperation)[Capitalize]> - ) => Promise | void -} & { - ready: () => Promise | void - packet: ( - packet: ClientPacketObject - ) => Promise | void - disconnect: (reason: DisconnectReason) => Promise | void -} +import { EventEmitter } from 'node:events' +import { + ClientOperation, + DisconnectReason, + Packet, + ServerOperation, + deserializePacket, + isClientPacket, + serializePacket, + uncapitalize, +} from '@revanced/bot-shared' + +import type TypedEmitter from 'typed-emitter' +import type { RawData, WebSocket } from 'ws' + +export default class Client { + id: string + disconnected: DisconnectReason | false = false + ready = false + + lastHeartbeat: number = null! + heartbeatInterval: number + + #hbTimeout: NodeJS.Timeout = null! + #emitter = new EventEmitter() as TypedEmitter + #socket: WebSocket + + constructor(options: ClientOptions) { + this.#socket = options.socket + this.heartbeatInterval = options.heartbeatInterval ?? 60000 + this.id = options.id + + this.#socket.on('error', () => this.forceDisconnect()) + this.#socket.on('close', () => this.forceDisconnect()) + this.#socket.on('unexpected-response', () => this.forceDisconnect()) + + this.send({ + op: ServerOperation.Hello, + d: { + heartbeatInterval: this.heartbeatInterval, + }, + }) + .then(() => { + this.#listen() + this.#listenHeartbeat() + this.ready = true + this.#emitter.emit('ready') + }) + .catch(() => { + if (this.disconnected === false) + this.disconnect(DisconnectReason.ServerError) + else this.forceDisconnect(DisconnectReason.ServerError) + }) + } + + on( + name: TOpName, + handler: ClientEventHandlers[typeof name], + ) { + this.#emitter.on(name, handler) + } + + once( + name: TOpName, + handler: ClientEventHandlers[typeof name], + ) { + this.#emitter.once(name, handler) + } + + off( + name: TOpName, + handler: ClientEventHandlers[typeof name], + ) { + this.#emitter.off(name, handler) + } + + send(packet: Packet) { + return new Promise((resolve, reject) => { + try { + this.#throwIfDisconnected( + 'Cannot send packet to client that has already disconnected', + ) + + this.#socket.send(serializePacket(packet), err => + err ? reject(err) : resolve(), + ) + } catch (e) { + reject(e) + } + }) + } + + async disconnect(reason: DisconnectReason = DisconnectReason.Generic) { + this.#throwIfDisconnected( + 'Cannot disconnect client that has already disconnected', + ) + + try { + await this.send({ op: ServerOperation.Disconnect, d: { reason } }) + } catch (err) { + throw new Error( + `Cannot send disconnect reason to client ${this.id}: ${err}`, + ) + } finally { + this.forceDisconnect(reason) + } + } + + forceDisconnect(reason: DisconnectReason = DisconnectReason.Generic) { + if (this.disconnected !== false) return + + if (this.#hbTimeout) clearTimeout(this.#hbTimeout) + this.#socket.terminate() + + this.ready = false + this.disconnected = reason + + this.#emitter.emit('disconnect', reason) + } + + #throwIfDisconnected(errorMessage: string) { + if (this.disconnected !== false) throw new Error(errorMessage) + + if (this.#socket.readyState !== this.#socket.OPEN) { + this.forceDisconnect(DisconnectReason.Generic) + throw new Error(errorMessage) + } + } + + #listen() { + this.#socket.on('message', data => { + try { + const rawPacket = deserializePacket(this._toBuffer(data)) + if (!isClientPacket(rawPacket)) throw null + + const packet: ClientPacketObject = { + ...rawPacket, + client: this, + } + + this.#emitter.emit('packet', packet) + this.#emitter.emit( + uncapitalize(ClientOperation[packet.op] as ClientEventName), + // @ts-expect-error TypeScript doesn't know that the above line will negate the type enough + packet, + ) + } catch (e) { + // TODO: add error fields to sent packet so we can log what went wrong + this.disconnect(DisconnectReason.InvalidPacket) + } + }) + } + + #listenHeartbeat() { + this.lastHeartbeat = Date.now() + this.#startHeartbeatTimeout() + + this.on('heartbeat', () => { + this.lastHeartbeat = Date.now() + this.#hbTimeout.refresh() + + this.send({ + op: ServerOperation.HeartbeatAck, + d: { + nextHeartbeat: this.lastHeartbeat + this.heartbeatInterval, + }, + }).catch(() => {}) + }) + } + + #startHeartbeatTimeout() { + this.#hbTimeout = setTimeout(() => { + if (Date.now() - this.lastHeartbeat > 0) { + // TODO: put into config + // 5000 is extra time to account for latency + const interval = setTimeout( + () => this.disconnect(DisconnectReason.TimedOut), + 5000, + ) + + this.once('heartbeat', () => clearTimeout(interval)) + // This should never happen but it did in my testing so I'm adding this just in case + this.once('disconnect', () => clearTimeout(interval)) + // Technically we don't have to do this, but JUST IN CASE! + } else this.#hbTimeout.refresh() + }, this.heartbeatInterval) + } + + protected _toBuffer(data: RawData) { + if (data instanceof Buffer) return data + else if (data instanceof ArrayBuffer) return Buffer.from(data) + else return Buffer.concat(data) + } +} + +export interface ClientOptions { + id: string + socket: WebSocket + heartbeatInterval?: number +} + +export type ClientPacketObject = Packet & { + client: Client +} + +export type ClientEventName = keyof typeof ClientOperation + +export type ClientEventHandlers = { + [K in Uncapitalize]: ( + packet: ClientPacketObject]>, + ) => Promise | void +} & { + ready: () => Promise | void + packet: ( + packet: ClientPacketObject, + ) => Promise | void + disconnect: (reason: DisconnectReason) => Promise | void +} diff --git a/apis/websocket/src/events/index.ts b/apis/websocket/src/events/index.ts index 53d9d03..eb97665 100755 --- a/apis/websocket/src/events/index.ts +++ b/apis/websocket/src/events/index.ts @@ -1,20 +1,20 @@ -import type { ClientOperation } from '@revanced/bot-shared' -import type { Wit } from 'node-wit' -import { ClientPacketObject } from '../classes/Client.js' -import type { Config } from '../utils/getConfig.js' -import type { Logger } from '../utils/logger.js' -import type { Worker as TesseractWorker } from 'tesseract.js' - -export { default as parseTextEventHandler } from './parseText.js' -export { default as parseImageEventHandler } from './parseImage.js' - -export type EventHandler = ( - packet: ClientPacketObject, - context: EventContext -) => void | Promise -export type EventContext = { - witClient: Wit - tesseractWorker: TesseractWorker - logger: Logger - config: Config -} +import type { ClientOperation } from '@revanced/bot-shared' +import type { Wit } from 'node-wit' +import type { Worker as TesseractWorker } from 'tesseract.js' +import { ClientPacketObject } from '../classes/Client.js' +import type { Config } from '../utils/getConfig.js' +import type { Logger } from '../utils/logger.js' + +export { default as parseTextEventHandler } from './parseText.js' +export { default as parseImageEventHandler } from './parseImage.js' + +export type EventHandler = ( + packet: ClientPacketObject, + context: EventContext, +) => void | Promise +export type EventContext = { + witClient: Wit + tesseractWorker: TesseractWorker + logger: Logger + config: Config +} diff --git a/apis/websocket/src/events/parseImage.ts b/apis/websocket/src/events/parseImage.ts index 53f8451..742f097 100755 --- a/apis/websocket/src/events/parseImage.ts +++ b/apis/websocket/src/events/parseImage.ts @@ -1,63 +1,63 @@ -import { ClientOperation, ServerOperation } from '@revanced/bot-shared' -import { AsyncQueue } from '@sapphire/async-queue' - -import type { EventHandler } from './index.js' - -const queue = new AsyncQueue() - -const parseImageEventHandler: EventHandler = async ( - packet, - { tesseractWorker, logger, config } -) => { - const { - client, - d: { image_url: imageUrl, id }, - } = packet - - logger.debug( - `Client ${client.id} requested to parse image from URL:`, - imageUrl - ) - logger.debug( - `Queue currently has ${queue.remaining}/${config.ocrConcurrentQueues} items in it` - ) - - if (queue.remaining < config.ocrConcurrentQueues) queue.shift() - await queue.wait() - - try { - logger.debug(`Recognizing image from URL for client ${client.id}`) - - const { data, jobId } = await tesseractWorker.recognize(imageUrl) - - logger.debug( - `Recognized image from URL for client ${client.id} (job ${jobId}):`, - data.text - ) - await client.send({ - op: ServerOperation.ParsedImage, - d: { - id, - text: data.text, - }, - }) - } catch { - logger.error( - `Failed to parse image from URL for client ${client.id}:`, - imageUrl - ) - await client.send({ - op: ServerOperation.ParseImageFailed, - d: { - id, - }, - }) - } finally { - queue.shift() - logger.debug( - `Finished processing image from URL for client ${client.id}, queue has ${queue.remaining}/${config.ocrConcurrentQueues} remaining items in it` - ) - } -} - -export default parseImageEventHandler +import { ClientOperation, ServerOperation } from '@revanced/bot-shared' +import { AsyncQueue } from '@sapphire/async-queue' + +import type { EventHandler } from './index.js' + +const queue = new AsyncQueue() + +const parseImageEventHandler: EventHandler = async ( + packet, + { tesseractWorker, logger, config }, +) => { + const { + client, + d: { image_url: imageUrl, id }, + } = packet + + logger.debug( + `Client ${client.id} requested to parse image from URL:`, + imageUrl, + ) + logger.debug( + `Queue currently has ${queue.remaining}/${config.ocrConcurrentQueues} items in it`, + ) + + if (queue.remaining < config.ocrConcurrentQueues) queue.shift() + await queue.wait() + + try { + logger.debug(`Recognizing image from URL for client ${client.id}`) + + const { data, jobId } = await tesseractWorker.recognize(imageUrl) + + logger.debug( + `Recognized image from URL for client ${client.id} (job ${jobId}):`, + data.text, + ) + await client.send({ + op: ServerOperation.ParsedImage, + d: { + id, + text: data.text, + }, + }) + } catch { + logger.error( + `Failed to parse image from URL for client ${client.id}:`, + imageUrl, + ) + await client.send({ + op: ServerOperation.ParseImageFailed, + d: { + id, + }, + }) + } finally { + queue.shift() + logger.debug( + `Finished processing image from URL for client ${client.id}, queue has ${queue.remaining}/${config.ocrConcurrentQueues} remaining items in it`, + ) + } +} + +export default parseImageEventHandler diff --git a/apis/websocket/src/events/parseText.ts b/apis/websocket/src/events/parseText.ts index 8fafbe1..7818036 100755 --- a/apis/websocket/src/events/parseText.ts +++ b/apis/websocket/src/events/parseText.ts @@ -1,43 +1,43 @@ -import { ClientOperation, ServerOperation } from '@revanced/bot-shared' - -import { inspect as inspectObject } from 'node:util' - -import type { EventHandler } from './index.js' - -const parseTextEventHandler: EventHandler = async ( - packet, - { witClient, logger } -) => { - const { - client, - d: { text, id }, - } = packet - - logger.debug(`Client ${client.id} requested to parse text:`, text) - - try { - const { intents } = await witClient.message(text, {}) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const intentsWithoutIds = intents.map(({ id, ...rest }) => rest) - - await client.send({ - op: ServerOperation.ParsedText, - d: { - id, - labels: intentsWithoutIds, - }, - }) - } catch (e) { - await client.send({ - op: ServerOperation.ParseTextFailed, - d: { - id, - }, - }) - - if (e instanceof Error) logger.error(e.stack ?? e.message) - else logger.error(inspectObject(e)) - } -} - -export default parseTextEventHandler +import { ClientOperation, ServerOperation } from '@revanced/bot-shared' + +import { inspect as inspectObject } from 'node:util' + +import type { EventHandler } from './index.js' + +const parseTextEventHandler: EventHandler = async ( + packet, + { witClient, logger }, +) => { + const { + client, + d: { text, id }, + } = packet + + logger.debug(`Client ${client.id} requested to parse text:`, text) + + try { + const { intents } = await witClient.message(text, {}) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const intentsWithoutIds = intents.map(({ id, ...rest }) => rest) + + await client.send({ + op: ServerOperation.ParsedText, + d: { + id, + labels: intentsWithoutIds, + }, + }) + } catch (e) { + await client.send({ + op: ServerOperation.ParseTextFailed, + d: { + id, + }, + }) + + if (e instanceof Error) logger.error(e.stack ?? e.message) + else logger.error(inspectObject(e)) + } +} + +export default parseTextEventHandler diff --git a/apis/websocket/src/index.ts b/apis/websocket/src/index.ts index 6281ab1..108341a 100755 --- a/apis/websocket/src/index.ts +++ b/apis/websocket/src/index.ts @@ -1,157 +1,157 @@ -import { fastify } from 'fastify' -import fastifyWebsocket from '@fastify/websocket' - -import { createWorker as createTesseractWorker } from 'tesseract.js' -import witPkg from 'node-wit' -const { Wit } = witPkg - -import { inspect as inspectObject } from 'node:util' - -import Client from './classes/Client.js' - -import { - EventContext, - parseImageEventHandler, - parseTextEventHandler, -} from './events/index.js' - -import { getConfig, checkEnv, logger } from './utils/index.js' -import { WebSocket } from 'ws' -import { - DisconnectReason, - HumanizedDisconnectReason, -} from '@revanced/bot-shared' - -// Check environment variables and load config -const environment = checkEnv(logger) -const config = getConfig() - -if (!config.debugLogsInProduction && environment === 'production') - logger.debug = () => {} - -// Workers and API clients - -const tesseractWorker = await createTesseractWorker('eng') -const witClient = new Wit({ - accessToken: process.env['WIT_AI_TOKEN']!, -}) - -process.on('beforeExit', () => tesseractWorker.terminate()) - -// Server logic - -const clients = new Set() -const clientSocketMap = new WeakMap() -const eventContext: EventContext = { - tesseractWorker, - logger, - witClient, - config, -} - -const server = fastify() - .register(fastifyWebsocket, { - options: { - // 16 KiB max payload - // A Discord message can not be longer than 4000 characters - // OCR should not be longer than 16000 characters - maxPayload: 16 * 1024, - }, - }) - .register(async instance => { - instance.get('/', { websocket: true }, async (connection, request) => { - try { - const client = new Client({ - socket: connection.socket, - id: request.hostname, - heartbeatInterval: config.clientHeartbeatInterval, - }) - - clientSocketMap.set(connection.socket, client) - clients.add(client) - - logger.debug(`Client ${client.id}'s instance has been added`) - logger.info( - `New client connected (now ${clients.size} clients) with ID:`, - client.id - ) - - client.on('disconnect', reason => { - clients.delete(client) - logger.info( - `Client ${client.id} disconnected because client ${HumanizedDisconnectReason[reason]}` - ) - }) - - client.on('parseText', async packet => - parseTextEventHandler(packet, eventContext) - ) - - client.on('parseImage', async packet => - parseImageEventHandler(packet, eventContext) - ) - - if ( - environment === 'development' && - !config.debugLogsInProduction - ) { - logger.debug( - 'Running development mode or debug logs in production is enabled, attaching debug events...' - ) - client.on('packet', ({ client, ...rawPacket }) => - logger.debug( - `Packet received from client ${client.id}:`, - inspectObject(rawPacket) - ) - ) - - client.on('heartbeat', () => - logger.debug( - 'Heartbeat received from client', - client.id - ) - ) - } - } catch (e) { - if (e instanceof Error) logger.error(e.stack ?? e.message) - else logger.error(inspectObject(e)) - - const client = clientSocketMap.get(connection.socket) - - if (!client) { - logger.error( - 'Missing client instance when encountering an error. If the instance still exists in memory, it will NOT be removed!' - ) - return connection.socket.terminate() - } - - if (client.disconnected === false) - client.disconnect(DisconnectReason.ServerError) - else client.forceDisconnect() - - clients.delete(client) - - logger.debug( - `Client ${client.id} disconnected because of an internal error` - ) - } - }) - }) - -// Start the server - -logger.debug('Starting with these configurations:', inspectObject(config)) - -await server.listen({ - host: config.address ?? '0.0.0.0', - port: config.port ?? 80, -}) - -const addressInfo = server.server.address() -if (!addressInfo || typeof addressInfo !== 'object') - logger.debug('Server started, but cannot determine address information') -else - logger.info( - 'Server started at:', - `${addressInfo.address}:${addressInfo.port}` - ) +import fastifyWebsocket from '@fastify/websocket' +import { fastify } from 'fastify' + +import witPkg from 'node-wit' +import { createWorker as createTesseractWorker } from 'tesseract.js' +const { Wit } = witPkg + +import { inspect as inspectObject } from 'node:util' + +import Client from './classes/Client.js' + +import { + EventContext, + parseImageEventHandler, + parseTextEventHandler, +} from './events/index.js' + +import { + DisconnectReason, + HumanizedDisconnectReason, +} from '@revanced/bot-shared' +import { WebSocket } from 'ws' +import { checkEnv, getConfig, logger } from './utils/index.js' + +// Check environment variables and load config +const environment = checkEnv(logger) +const config = getConfig() + +if (!config.debugLogsInProduction && environment === 'production') + logger.debug = () => {} + +// Workers and API clients + +const tesseractWorker = await createTesseractWorker('eng') +const witClient = new Wit({ + accessToken: process.env['WIT_AI_TOKEN']!, +}) + +process.on('beforeExit', () => tesseractWorker.terminate()) + +// Server logic + +const clients = new Set() +const clientSocketMap = new WeakMap() +const eventContext: EventContext = { + tesseractWorker, + logger, + witClient, + config, +} + +const server = fastify() + .register(fastifyWebsocket, { + options: { + // 16 KiB max payload + // A Discord message can not be longer than 4000 characters + // OCR should not be longer than 16000 characters + maxPayload: 16 * 1024, + }, + }) + .register(async instance => { + instance.get('/', { websocket: true }, async (connection, request) => { + try { + const client = new Client({ + socket: connection.socket, + id: request.hostname, + heartbeatInterval: config.clientHeartbeatInterval, + }) + + clientSocketMap.set(connection.socket, client) + clients.add(client) + + logger.debug(`Client ${client.id}'s instance has been added`) + logger.info( + `New client connected (now ${clients.size} clients) with ID:`, + client.id, + ) + + client.on('disconnect', reason => { + clients.delete(client) + logger.info( + `Client ${client.id} disconnected because client ${HumanizedDisconnectReason[reason]}`, + ) + }) + + client.on('parseText', async packet => + parseTextEventHandler(packet, eventContext), + ) + + client.on('parseImage', async packet => + parseImageEventHandler(packet, eventContext), + ) + + if ( + environment === 'development' && + !config.debugLogsInProduction + ) { + logger.debug( + 'Running development mode or debug logs in production is enabled, attaching debug events...', + ) + client.on('packet', ({ client, ...rawPacket }) => + logger.debug( + `Packet received from client ${client.id}:`, + inspectObject(rawPacket), + ), + ) + + client.on('heartbeat', () => + logger.debug( + 'Heartbeat received from client', + client.id, + ), + ) + } + } catch (e) { + if (e instanceof Error) logger.error(e.stack ?? e.message) + else logger.error(inspectObject(e)) + + const client = clientSocketMap.get(connection.socket) + + if (!client) { + logger.error( + 'Missing client instance when encountering an error. If the instance still exists in memory, it will NOT be removed!', + ) + return connection.socket.terminate() + } + + if (client.disconnected === false) + client.disconnect(DisconnectReason.ServerError) + else client.forceDisconnect() + + clients.delete(client) + + logger.debug( + `Client ${client.id} disconnected because of an internal error`, + ) + } + }) + }) + +// Start the server + +logger.debug('Starting with these configurations:', inspectObject(config)) + +await server.listen({ + host: config.address ?? '0.0.0.0', + port: config.port ?? 80, +}) + +const addressInfo = server.server.address() +if (!addressInfo || typeof addressInfo !== 'object') + logger.debug('Server started, but cannot determine address information') +else + logger.info( + 'Server started at:', + `${addressInfo.address}:${addressInfo.port}`, + ) diff --git a/apis/websocket/src/types.d.ts b/apis/websocket/src/types.d.ts index 27a69ed..41da923 100755 --- a/apis/websocket/src/types.d.ts +++ b/apis/websocket/src/types.d.ts @@ -1,9 +1,9 @@ -declare global { - namespace NodeJS { - interface ProcessEnv { - WIT_AI_TOKEN?: string - } - } -} - -declare type NodeEnvironment = 'development' | 'production' +declare global { + namespace NodeJS { + interface ProcessEnv { + WIT_AI_TOKEN?: string + } + } +} + +declare type NodeEnvironment = 'development' | 'production' diff --git a/apis/websocket/src/utils/checkEnv.ts b/apis/websocket/src/utils/checkEnv.ts index 00cee6d..3c503e2 100755 --- a/apis/websocket/src/utils/checkEnv.ts +++ b/apis/websocket/src/utils/checkEnv.ts @@ -1,31 +1,31 @@ -import type { Logger } from './logger.js' - -export default function checkEnv(logger: Logger) { - if (!process.env['NODE_ENV']) - logger.warn('NODE_ENV not set, defaulting to `development`') - const environment = (process.env['NODE_ENV'] ?? - 'development') as NodeEnvironment - - if (!['development', 'production'].includes(environment)) { - logger.error( - 'NODE_ENV is neither `development` nor `production`, unable to determine environment' - ) - logger.info('Set NODE_ENV to blank to use `development` mode') - process.exit(1) - } - - logger.info(`Running in ${environment} mode...`) - - if (environment === 'production' && process.env['IS_USING_DOT_ENV']) { - logger.warn( - 'You seem to be using .env files, this is generally not a good idea in production...' - ) - } - - if (!process.env['WIT_AI_TOKEN']) { - logger.error('WIT_AI_TOKEN is not defined in the environment variables') - process.exit(1) - } - - return environment -} +import type { Logger } from './logger.js' + +export default function checkEnv(logger: Logger) { + if (!process.env['NODE_ENV']) + logger.warn('NODE_ENV not set, defaulting to `development`') + const environment = (process.env['NODE_ENV'] ?? + 'development') as NodeEnvironment + + if (!['development', 'production'].includes(environment)) { + logger.error( + 'NODE_ENV is neither `development` nor `production`, unable to determine environment', + ) + logger.info('Set NODE_ENV to blank to use `development` mode') + process.exit(1) + } + + logger.info(`Running in ${environment} mode...`) + + if (environment === 'production' && process.env['IS_USING_DOT_ENV']) { + logger.warn( + 'You seem to be using .env files, this is generally not a good idea in production...', + ) + } + + if (!process.env['WIT_AI_TOKEN']) { + logger.error('WIT_AI_TOKEN is not defined in the environment variables') + process.exit(1) + } + + return environment +} diff --git a/apis/websocket/src/utils/getConfig.ts b/apis/websocket/src/utils/getConfig.ts index 384b695..f3168cb 100755 --- a/apis/websocket/src/utils/getConfig.ts +++ b/apis/websocket/src/utils/getConfig.ts @@ -1,40 +1,40 @@ -import { existsSync } from 'node:fs' -import { resolve as resolvePath } from 'node:path' -import { pathToFileURL } from 'node:url' - -const configPath = resolvePath(process.cwd(), 'config.json') - -const userConfig: Partial = existsSync(configPath) - ? ( - await import(pathToFileURL(configPath).href, { - assert: { - type: 'json', - }, - }) - ).default - : {} - -type BaseTypeOf = T extends (infer U)[] - ? U[] - : T extends (...args: unknown[]) => infer U - ? (...args: unknown[]) => U - : T extends object - ? { [K in keyof T]: T[K] } - : T - -export type Config = Omit< - BaseTypeOf, - '$schema' -> - -export const defaultConfig: Config = { - address: '127.0.0.1', - port: 80, - ocrConcurrentQueues: 1, - clientHeartbeatInterval: 60000, - debugLogsInProduction: false, -} - -export default function getConfig() { - return Object.assign(defaultConfig, userConfig) satisfies Config -} +import { existsSync } from 'node:fs' +import { resolve as resolvePath } from 'node:path' +import { pathToFileURL } from 'node:url' + +const configPath = resolvePath(process.cwd(), 'config.json') + +const userConfig: Partial = existsSync(configPath) + ? ( + await import(pathToFileURL(configPath).href, { + assert: { + type: 'json', + }, + }) + ).default + : {} + +type BaseTypeOf = T extends (infer U)[] + ? U[] + : T extends (...args: unknown[]) => infer U + ? (...args: unknown[]) => U + : T extends object + ? { [K in keyof T]: T[K] } + : T + +export type Config = Omit< + BaseTypeOf, + '$schema' +> + +export const defaultConfig: Config = { + address: '127.0.0.1', + port: 80, + ocrConcurrentQueues: 1, + clientHeartbeatInterval: 60000, + debugLogsInProduction: false, +} + +export default function getConfig() { + return Object.assign(defaultConfig, userConfig) satisfies Config +} diff --git a/apis/websocket/src/utils/index.ts b/apis/websocket/src/utils/index.ts index 913395a..2dcb7a6 100755 --- a/apis/websocket/src/utils/index.ts +++ b/apis/websocket/src/utils/index.ts @@ -1,3 +1,3 @@ -export { default as getConfig } from './getConfig.js' -export { default as checkEnv } from './checkEnv.js' -export { default as logger } from './logger.js' +export { default as getConfig } from './getConfig.js' +export { default as checkEnv } from './checkEnv.js' +export { default as logger } from './logger.js' diff --git a/apis/websocket/src/utils/logger.ts b/apis/websocket/src/utils/logger.ts index 96ef157..88c870d 100755 --- a/apis/websocket/src/utils/logger.ts +++ b/apis/websocket/src/utils/logger.ts @@ -1,25 +1,25 @@ -import { Chalk } from 'chalk' - -const chalk = new Chalk() -const logger = { - debug: (...args) => console.debug(chalk.gray('DEBUG:', ...args)), - info: (...args) => - console.info(chalk.bgBlue.whiteBright(' INFO '), ...args), - warn: (...args) => - console.warn( - chalk.bgYellow.blackBright.bold(' WARN '), - chalk.yellowBright(...args) - ), - error: (...args) => - console.error( - chalk.bgRed.whiteBright.bold(' ERROR '), - chalk.redBright(...args) - ), - log: console.log, -} satisfies Logger - -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' -export type LogFunction = (...x: unknown[]) => void -export type Logger = Record - -export default logger +import { Chalk } from 'chalk' + +const chalk = new Chalk() +const logger = { + debug: (...args) => console.debug(chalk.gray('DEBUG:', ...args)), + info: (...args) => + console.info(chalk.bgBlue.whiteBright(' INFO '), ...args), + warn: (...args) => + console.warn( + chalk.bgYellow.blackBright.bold(' WARN '), + chalk.yellowBright(...args), + ), + error: (...args) => + console.error( + chalk.bgRed.whiteBright.bold(' ERROR '), + chalk.redBright(...args), + ), + log: console.log, +} satisfies Logger + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' +export type LogFunction = (...x: unknown[]) => void +export type Logger = Record + +export default logger diff --git a/packages/api/src/classes/Client.ts b/packages/api/src/classes/Client.ts index e7e925a..5558a41 100755 --- a/packages/api/src/classes/Client.ts +++ b/packages/api/src/classes/Client.ts @@ -1,177 +1,177 @@ -import { ClientOperation, Packet, ServerOperation } from '@revanced/bot-shared' -import ClientGateway, { ClientGatewayEventHandlers } from './ClientGateway.js' - -/** - * The client that connects to the API. - */ -export default class Client { - ready: boolean = false - gateway: ClientGateway - #parseId: number = 0 - - constructor(options: ClientOptions) { - this.gateway = new ClientGateway({ - url: options.api.gatewayUrl, - }) - - this.gateway.on('ready', () => { - this.ready = true - }) - } - - /** - * Connects to the WebSocket API - * @returns A promise that resolves when the client is ready - */ - connect() { - return this.gateway.connect() - } - - /** - * Checks whether the client is ready - * @returns Whether the client is ready - */ - isReady(): this is ReadiedClient { - return this.ready - } - - /** - * Requests the API to parse the given text - * @param text The text to parse - * @returns An object containing the ID of the request and the labels - */ - async parseText(text: string) { - this.#throwIfNotReady() - - const currentId = (this.#parseId++).toString() - - this.gateway.send({ - op: ClientOperation.ParseText, - d: { - text, - id: currentId, - }, - }) - - type CorrectPacket = Packet - - const promise = new Promise((rs, rj) => { - const parsedTextListener = (packet: CorrectPacket) => { - if (packet.d.id !== currentId) return - this.gateway.off('parsedText', parsedTextListener) - rs(packet.d) - } - - const parseTextFailedListener = ( - packet: Packet - ) => { - if (packet.d.id !== currentId) return - this.gateway.off('parseTextFailed', parseTextFailedListener) - rj() - } - - this.gateway.on('parsedText', parsedTextListener) - this.gateway.on('parseTextFailed', parseTextFailedListener) - }) - - return await promise - } - - /** - * Requests the API to parse the given image and return the text - * @param url The URL of the image - * @returns An object containing the ID of the request and the parsed text - */ - async parseImage(url: string) { - this.#throwIfNotReady() - - const currentId = (this.#parseId++).toString() - - this.gateway.send({ - op: ClientOperation.ParseImage, - d: { - image_url: url, - id: currentId, - }, - }) - - type CorrectPacket = Packet - - const promise = new Promise((rs, rj) => { - const parsedImageListener = (packet: CorrectPacket) => { - if (packet.d.id !== currentId) return - this.gateway.off('parsedImage', parsedImageListener) - rs(packet.d) - } - - const parseImageFailedListener = ( - packet: Packet - ) => { - if (packet.d.id !== currentId) return - this.gateway.off('parseImageFailed', parseImageFailedListener) - rj() - } - - this.gateway.on('parsedImage', parsedImageListener) - this.gateway.on('parseImageFailed', parseImageFailedListener) - }) - - return await promise - } - - /** - * Adds an event listener - * @param name The event name to listen for - * @param handler The event handler - * @returns The event handler function - */ - on( - name: TOpName, - handler: ClientGatewayEventHandlers[TOpName] - ) { - this.gateway.on(name, handler) - return handler - } - - /** - * Removes an event listener - * @param name The event name to remove a listener from - * @param handler The event handler to remove - * @returns The removed event handler function - */ - off( - name: TOpName, - handler: ClientGatewayEventHandlers[TOpName] - ) { - this.gateway.off(name, handler) - return handler - } - - /** - * Adds an event listener that will only be called once - * @param name The event name to listen for - * @param handler The event handler - * @returns The event handler function - */ - once( - name: TOpName, - handler: ClientGatewayEventHandlers[TOpName] - ) { - this.gateway.once(name, handler) - return handler - } - - #throwIfNotReady() { - if (!this.isReady()) throw new Error('Client is not ready') - } -} - -export type ReadiedClient = Client & { ready: true } - -export interface ClientOptions { - api: ClientApiOptions -} - -export interface ClientApiOptions { - gatewayUrl: string -} +import { ClientOperation, Packet, ServerOperation } from '@revanced/bot-shared' +import ClientGateway, { ClientGatewayEventHandlers } from './ClientGateway.js' + +/** + * The client that connects to the API. + */ +export default class Client { + ready = false + gateway: ClientGateway + #parseId = 0 + + constructor(options: ClientOptions) { + this.gateway = new ClientGateway({ + url: options.api.gatewayUrl, + }) + + this.gateway.on('ready', () => { + this.ready = true + }) + } + + /** + * Connects to the WebSocket API + * @returns A promise that resolves when the client is ready + */ + connect() { + return this.gateway.connect() + } + + /** + * Checks whether the client is ready + * @returns Whether the client is ready + */ + isReady(): this is ReadiedClient { + return this.ready + } + + /** + * Requests the API to parse the given text + * @param text The text to parse + * @returns An object containing the ID of the request and the labels + */ + async parseText(text: string) { + this.#throwIfNotReady() + + const currentId = (this.#parseId++).toString() + + this.gateway.send({ + op: ClientOperation.ParseText, + d: { + text, + id: currentId, + }, + }) + + type CorrectPacket = Packet + + const promise = new Promise((rs, rj) => { + const parsedTextListener = (packet: CorrectPacket) => { + if (packet.d.id !== currentId) return + this.gateway.off('parsedText', parsedTextListener) + rs(packet.d) + } + + const parseTextFailedListener = ( + packet: Packet, + ) => { + if (packet.d.id !== currentId) return + this.gateway.off('parseTextFailed', parseTextFailedListener) + rj() + } + + this.gateway.on('parsedText', parsedTextListener) + this.gateway.on('parseTextFailed', parseTextFailedListener) + }) + + return await promise + } + + /** + * Requests the API to parse the given image and return the text + * @param url The URL of the image + * @returns An object containing the ID of the request and the parsed text + */ + async parseImage(url: string) { + this.#throwIfNotReady() + + const currentId = (this.#parseId++).toString() + + this.gateway.send({ + op: ClientOperation.ParseImage, + d: { + image_url: url, + id: currentId, + }, + }) + + type CorrectPacket = Packet + + const promise = new Promise((rs, rj) => { + const parsedImageListener = (packet: CorrectPacket) => { + if (packet.d.id !== currentId) return + this.gateway.off('parsedImage', parsedImageListener) + rs(packet.d) + } + + const parseImageFailedListener = ( + packet: Packet, + ) => { + if (packet.d.id !== currentId) return + this.gateway.off('parseImageFailed', parseImageFailedListener) + rj() + } + + this.gateway.on('parsedImage', parsedImageListener) + this.gateway.on('parseImageFailed', parseImageFailedListener) + }) + + return await promise + } + + /** + * Adds an event listener + * @param name The event name to listen for + * @param handler The event handler + * @returns The event handler function + */ + on( + name: TOpName, + handler: ClientGatewayEventHandlers[TOpName], + ) { + this.gateway.on(name, handler) + return handler + } + + /** + * Removes an event listener + * @param name The event name to remove a listener from + * @param handler The event handler to remove + * @returns The removed event handler function + */ + off( + name: TOpName, + handler: ClientGatewayEventHandlers[TOpName], + ) { + this.gateway.off(name, handler) + return handler + } + + /** + * Adds an event listener that will only be called once + * @param name The event name to listen for + * @param handler The event handler + * @returns The event handler function + */ + once( + name: TOpName, + handler: ClientGatewayEventHandlers[TOpName], + ) { + this.gateway.once(name, handler) + return handler + } + + #throwIfNotReady() { + if (!this.isReady()) throw new Error('Client is not ready') + } +} + +export type ReadiedClient = Client & { ready: true } + +export interface ClientOptions { + api: ClientApiOptions +} + +export interface ClientApiOptions { + gatewayUrl: string +} diff --git a/packages/api/src/classes/ClientGateway.ts b/packages/api/src/classes/ClientGateway.ts index ef4b2da..5ca560f 100755 --- a/packages/api/src/classes/ClientGateway.ts +++ b/packages/api/src/classes/ClientGateway.ts @@ -1,234 +1,235 @@ -import { type RawData, WebSocket } from 'ws' -import type TypedEmitter from 'typed-emitter' -import { - ClientOperation, - DisconnectReason, - Packet, - ServerOperation, - deserializePacket, - isServerPacket, - serializePacket, - uncapitalize, -} from '@revanced/bot-shared' -import { EventEmitter } from 'events' - -/** - * 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 { - readonly url: string - ready: boolean = false - disconnected: boolean | DisconnectReason = DisconnectReason.NeverConnected - config: Readonly['d']> | null = null! - - #hbTimeout: NodeJS.Timeout = null! - #socket: WebSocket = null! - #emitter = new EventEmitter() as TypedEmitter - - constructor(options: ClientGatewayOptions) { - this.url = options.url - } - - /** - * Connects to the WebSocket API - * @returns A promise that resolves when the client is ready - */ - connect() { - return new Promise((rs, rj) => { - try { - this.#socket = new WebSocket(this.url) - - this.#socket.on('open', () => { - this.disconnected = false - rs() - }) - - this.#socket.on('close', () => - this.#handleDisconnect(DisconnectReason.Generic) - ) - - this.#listen() - this.ready = true - this.#emitter.emit('ready') - } catch (e) { - rj(e) - } - }) - } - - /** - * Adds an event listener - * @param name The event name to listen for - * @param handler The event handler - * @returns The event handler function - */ - on( - name: TOpName, - handler: ClientGatewayEventHandlers[typeof name] - ) { - this.#emitter.on(name, handler) - } - - /** - * Removes an event listener - * @param name The event name to remove a listener from - * @param handler The event handler to remove - * @returns The removed event handler function - */ - off( - name: TOpName, - handler: ClientGatewayEventHandlers[typeof name] - ) { - this.#emitter.off(name, handler) - } - - /** - * Adds an event listener that will only be called once - * @param name The event name to listen for - * @param handler The event handler - * @returns The event handler function - */ - once( - name: TOpName, - handler: ClientGatewayEventHandlers[typeof name] - ) { - this.#emitter.once(name, handler) - } - - /** - * Sends a packet to the server - * @param packet The packet to send - * @returns A promise that resolves when the packet has been sent - */ - send(packet: Packet) { - this.#throwIfDisconnected( - 'Cannot send a packet when already disconnected from the server' - ) - - return new Promise((resolve, reject) => - this.#socket.send(serializePacket(packet), err => - err ? reject(err) : resolve() - ) - ) - } - - /** - * Disconnects from the WebSocket API - */ - disconnect() { - this.#throwIfDisconnected( - 'Cannot disconnect when already disconnected from the server' - ) - - this.#handleDisconnect(DisconnectReason.Generic) - } - - /** - * Checks whether the client is ready - * @returns Whether the client is ready - */ - isReady(): this is ReadiedClientGateway { - return this.ready - } - - #listen() { - this.#socket.on('message', data => { - const packet = deserializePacket(this._toBuffer(data)) - // TODO: maybe log this? - // Just ignore the invalid packet, we don't have to disconnect - if (!isServerPacket(packet)) return - - this.#emitter.emit('packet', packet) - - switch (packet.op) { - case ServerOperation.Hello: - // eslint-disable-next-line no-case-declarations - const data = Object.freeze( - (packet as Packet).d - ) - this.config = data - this.#emitter.emit('hello', data) - this.#startHeartbeating() - break - case ServerOperation.Disconnect: - return this.#handleDisconnect( - (packet as Packet).d.reason - ) - default: - return this.#emitter.emit( - uncapitalize( - ServerOperation[ - packet.op - ] as ClientGatewayServerEventName - ), - // @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) - if (this.#socket.readyState !== this.#socket.OPEN) - throw new Error(errorMessage) - } - - #handleDisconnect(reason: DisconnectReason) { - clearTimeout(this.#hbTimeout) - this.disconnected = reason - this.#socket.close() - - this.#emitter.emit('disconnect', reason) - } - - #startHeartbeating() { - this.on('heartbeatAck', packet => { - this.#hbTimeout = setTimeout(() => { - this.send({ - op: ClientOperation.Heartbeat, - d: null, - }) - }, packet.d.nextHeartbeat - Date.now()) - }) - - // Immediately send a heartbeat so we can get when to send the next one - this.send({ - op: ClientOperation.Heartbeat, - d: null, - }) - } - - protected _toBuffer(data: RawData) { - if (data instanceof Buffer) return data - else if (data instanceof ArrayBuffer) return Buffer.from(data) - else return Buffer.concat(data) - } -} - -export interface ClientGatewayOptions { - /** - * The gateway URL to connect to - */ - url: string -} - -export type ClientGatewayServerEventName = keyof typeof ServerOperation - -export type ClientGatewayEventHandlers = { - [K in Uncapitalize]: ( - packet: Packet<(typeof ServerOperation)[Capitalize]> - ) => Promise | void -} & { - hello: ( - config: NonNullable - ) => Promise | void - ready: () => Promise | void - packet: (packet: Packet) => Promise | void - disconnect: (reason: DisconnectReason) => Promise | void -} - -export type ReadiedClientGateway = RequiredProperty< - InstanceType -> +import { EventEmitter } from 'events' +import { + ClientOperation, + DisconnectReason, + Packet, + ServerOperation, + deserializePacket, + isServerPacket, + serializePacket, + uncapitalize, +} from '@revanced/bot-shared' +import type TypedEmitter from 'typed-emitter' +import { type 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 { + readonly url: string + ready = false + disconnected: boolean | DisconnectReason = DisconnectReason.NeverConnected + config: Readonly['d']> | null = null! + + #hbTimeout: NodeJS.Timeout = null! + #socket: WebSocket = null! + #emitter = new EventEmitter() as TypedEmitter + + constructor(options: ClientGatewayOptions) { + this.url = options.url + } + + /** + * Connects to the WebSocket API + * @returns A promise that resolves when the client is ready + */ + connect() { + return new Promise((rs, rj) => { + try { + this.#socket = new WebSocket(this.url) + + this.#socket.on('open', () => { + this.disconnected = false + rs() + }) + + this.#socket.on('close', () => + this.#handleDisconnect(DisconnectReason.Generic), + ) + + this.#listen() + this.ready = true + this.#emitter.emit('ready') + } catch (e) { + rj(e) + } + }) + } + + /** + * Adds an event listener + * @param name The event name to listen for + * @param handler The event handler + * @returns The event handler function + */ + on( + name: TOpName, + handler: ClientGatewayEventHandlers[typeof name], + ) { + this.#emitter.on(name, handler) + } + + /** + * Removes an event listener + * @param name The event name to remove a listener from + * @param handler The event handler to remove + * @returns The removed event handler function + */ + off( + name: TOpName, + handler: ClientGatewayEventHandlers[typeof name], + ) { + this.#emitter.off(name, handler) + } + + /** + * Adds an event listener that will only be called once + * @param name The event name to listen for + * @param handler The event handler + * @returns The event handler function + */ + once( + name: TOpName, + handler: ClientGatewayEventHandlers[typeof name], + ) { + this.#emitter.once(name, handler) + } + + /** + * Sends a packet to the server + * @param packet The packet to send + * @returns A promise that resolves when the packet has been sent + */ + send(packet: Packet) { + this.#throwIfDisconnected( + 'Cannot send a packet when already disconnected from the server', + ) + + return new Promise((resolve, reject) => + this.#socket.send(serializePacket(packet), err => + err ? reject(err) : resolve(), + ), + ) + } + + /** + * Disconnects from the WebSocket API + */ + disconnect() { + this.#throwIfDisconnected( + 'Cannot disconnect when already disconnected from the server', + ) + + this.#handleDisconnect(DisconnectReason.Generic) + } + + /** + * Checks whether the client is ready + * @returns Whether the client is ready + */ + isReady(): this is ReadiedClientGateway { + return this.ready + } + + #listen() { + this.#socket.on('message', data => { + const packet = deserializePacket(this._toBuffer(data)) + // TODO: maybe log this? + // Just ignore the invalid packet, we don't have to disconnect + if (!isServerPacket(packet)) return + + this.#emitter.emit('packet', packet) + + switch (packet.op) { + case ServerOperation.Hello: { + // eslint-disable-next-line no-case-declarations + const data = Object.freeze( + (packet as Packet).d, + ) + this.config = data + this.#emitter.emit('hello', data) + this.#startHeartbeating() + break + } + case ServerOperation.Disconnect: + return this.#handleDisconnect( + (packet as Packet).d.reason, + ) + default: + return this.#emitter.emit( + uncapitalize( + ServerOperation[ + packet.op + ] as ClientGatewayServerEventName, + ), + // @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) + if (this.#socket.readyState !== this.#socket.OPEN) + throw new Error(errorMessage) + } + + #handleDisconnect(reason: DisconnectReason) { + clearTimeout(this.#hbTimeout) + this.disconnected = reason + this.#socket.close() + + this.#emitter.emit('disconnect', reason) + } + + #startHeartbeating() { + this.on('heartbeatAck', packet => { + this.#hbTimeout = setTimeout(() => { + this.send({ + op: ClientOperation.Heartbeat, + d: null, + }) + }, packet.d.nextHeartbeat - Date.now()) + }) + + // Immediately send a heartbeat so we can get when to send the next one + this.send({ + op: ClientOperation.Heartbeat, + d: null, + }) + } + + protected _toBuffer(data: RawData) { + if (data instanceof Buffer) return data + else if (data instanceof ArrayBuffer) return Buffer.from(data) + else return Buffer.concat(data) + } +} + +export interface ClientGatewayOptions { + /** + * The gateway URL to connect to + */ + url: string +} + +export type ClientGatewayServerEventName = keyof typeof ServerOperation + +export type ClientGatewayEventHandlers = { + [K in Uncapitalize]: ( + packet: Packet]>, + ) => Promise | void +} & { + hello: ( + config: NonNullable, + ) => Promise | void + ready: () => Promise | void + packet: (packet: Packet) => Promise | void + disconnect: (reason: DisconnectReason) => Promise | void +} + +export type ReadiedClientGateway = RequiredProperty< + InstanceType +> diff --git a/packages/api/src/classes/index.ts b/packages/api/src/classes/index.ts index e02f863..585b439 100755 --- a/packages/api/src/classes/index.ts +++ b/packages/api/src/classes/index.ts @@ -1,4 +1,4 @@ -export { default as Client } from './Client.js' -export * from './Client.js' -export { default as ClientGateway } from './ClientGateway.js' -export * from './ClientGateway.js' +export { default as Client } from './Client.js' +export * from './Client.js' +export { default as ClientGateway } from './ClientGateway.js' +export * from './ClientGateway.js' diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d5e08ef..b090345 100755 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1 +1 @@ -export * from './classes/index.js' +export * from './classes/index.js' diff --git a/packages/api/utility-types.d.ts b/packages/api/utility-types.d.ts index 1710e17..778651e 100755 --- a/packages/api/utility-types.d.ts +++ b/packages/api/utility-types.d.ts @@ -1 +1 @@ -type RequiredProperty = { [P in keyof T]: Required> } +type RequiredProperty = { [P in keyof T]: Required> } diff --git a/packages/shared/src/constants/DisconnectReason.ts b/packages/shared/src/constants/DisconnectReason.ts index 051d5e3..6935095 100755 --- a/packages/shared/src/constants/DisconnectReason.ts +++ b/packages/shared/src/constants/DisconnectReason.ts @@ -1,27 +1,27 @@ -/** - * Disconnect reasons for clients - */ -enum DisconnectReason { - /** - * Unknown reason - */ - Generic = 1, - /** - * The client did not respond in time - */ - TimedOut, - /** - * The client sent an invalid packet (unserializable or invalid JSON) - */ - InvalidPacket, - /** - * The server has encountered an internal error - */ - ServerError, - /** - * The client had never connected to the server (**CLIENT-ONLY**) - */ - NeverConnected, -} - -export default DisconnectReason +/** + * Disconnect reasons for clients + */ +enum DisconnectReason { + /** + * Unknown reason + */ + Generic = 1, + /** + * The client did not respond in time + */ + TimedOut = 2, + /** + * The client sent an invalid packet (unserializable or invalid JSON) + */ + InvalidPacket = 3, + /** + * The server has encountered an internal error + */ + ServerError = 4, + /** + * The client had never connected to the server (**CLIENT-ONLY**) + */ + NeverConnected = 5, +} + +export default DisconnectReason diff --git a/packages/shared/src/constants/HumanizedDisconnectReason.ts b/packages/shared/src/constants/HumanizedDisconnectReason.ts index fa88738..0687d98 100755 --- a/packages/shared/src/constants/HumanizedDisconnectReason.ts +++ b/packages/shared/src/constants/HumanizedDisconnectReason.ts @@ -1,15 +1,15 @@ -import DisconnectReason from './DisconnectReason.js' - -/** - * Humanized disconnect reasons for logs - */ -const HumanizedDisconnectReason = { - [DisconnectReason.InvalidPacket]: 'has sent invalid packet', - [DisconnectReason.Generic]: 'has been disconnected for unknown reasons', - [DisconnectReason.TimedOut]: 'has timed out', - [DisconnectReason.ServerError]: - 'has been disconnected due to an internal server error', - [DisconnectReason.NeverConnected]: 'had never connected to the server', -} as const satisfies Record - -export default HumanizedDisconnectReason +import DisconnectReason from './DisconnectReason.js' + +/** + * Humanized disconnect reasons for logs + */ +const HumanizedDisconnectReason = { + [DisconnectReason.InvalidPacket]: 'has sent invalid packet', + [DisconnectReason.Generic]: 'has been disconnected for unknown reasons', + [DisconnectReason.TimedOut]: 'has timed out', + [DisconnectReason.ServerError]: + 'has been disconnected due to an internal server error', + [DisconnectReason.NeverConnected]: 'had never connected to the server', +} as const satisfies Record + +export default HumanizedDisconnectReason diff --git a/packages/shared/src/constants/Operation.ts b/packages/shared/src/constants/Operation.ts index dcd0b96..9c4ee4e 100755 --- a/packages/shared/src/constants/Operation.ts +++ b/packages/shared/src/constants/Operation.ts @@ -1,57 +1,57 @@ -/** - * Client operation codes for the gateway - */ -export enum ClientOperation { - /** - * Client's heartbeat (to check if the connection is dead or not) - */ - Heartbeat = 100, - - /** - * Client's request to parse text - */ - ParseText = 110, - /** - * Client's request to parse image - */ - ParseImage, -} - -/** - * Server operation codes for the gateway - */ -export enum ServerOperation { - /** - * Server's acknowledgement of a client's heartbeat - */ - HeartbeatAck = 1, - /** - * Server's initial response to a client's connection - */ - Hello, - - /** - * Server's response to client's request to parse text - */ - ParsedText = 10, - /** - * Server's response to client's request to parse image - */ - ParsedImage, - /** - * Server's failure response to client's request to parse text - */ - ParseTextFailed, - /** - * Server's failure response to client's request to parse image - */ - ParseImageFailed, - - /** - * Server's disconnect message - */ - Disconnect = 20, -} - -export const Operation = { ...ClientOperation, ...ServerOperation } as const -export type Operation = ClientOperation | ServerOperation +/** + * Client operation codes for the gateway + */ +export enum ClientOperation { + /** + * Client's heartbeat (to check if the connection is dead or not) + */ + Heartbeat = 100, + + /** + * Client's request to parse text + */ + ParseText = 110, + /** + * Client's request to parse image + */ + ParseImage = 111, +} + +/** + * Server operation codes for the gateway + */ +export enum ServerOperation { + /** + * Server's acknowledgement of a client's heartbeat + */ + HeartbeatAck = 1, + /** + * Server's initial response to a client's connection + */ + Hello = 2, + + /** + * Server's response to client's request to parse text + */ + ParsedText = 10, + /** + * Server's response to client's request to parse image + */ + ParsedImage = 11, + /** + * Server's failure response to client's request to parse text + */ + ParseTextFailed = 12, + /** + * Server's failure response to client's request to parse image + */ + ParseImageFailed = 13, + + /** + * Server's disconnect message + */ + Disconnect = 20, +} + +export const Operation = { ...ClientOperation, ...ServerOperation } as const +export type Operation = ClientOperation | ServerOperation diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 097859d..98ae4d4 100755 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,3 +1,3 @@ -export { default as DisconnectReason } from './DisconnectReason.js' -export { default as HumanizedDisconnectReason } from './HumanizedDisconnectReason.js' -export * from './Operation.js' +export { default as DisconnectReason } from './DisconnectReason.js' +export { default as HumanizedDisconnectReason } from './HumanizedDisconnectReason.js' +export * from './Operation.js' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e5ef662..b143126 100755 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,3 @@ -export * from './constants/index.js' -export * from './schemas/index.js' -export * from './utils/index.js' +export * from './constants/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/packages/shared/src/schemas/Packet.ts b/packages/shared/src/schemas/Packet.ts index 34148fa..6c133b0 100755 --- a/packages/shared/src/schemas/Packet.ts +++ b/packages/shared/src/schemas/Packet.ts @@ -1,101 +1,101 @@ -import DisconnectReason from '../constants/DisconnectReason.js' -import { - ClientOperation, - Operation, - ServerOperation, -} from '../constants/Operation.js' -import { - object, - enum_, - special, - ObjectSchema, - number, - string, - Output, - AnySchema, - null_, - NullSchema, - array, - url, - parse, - // merge -} from 'valibot' - -/** - * Schema to validate packets - */ -export const PacketSchema = special(input => { - if ( - typeof input === 'object' && - input && - 'op' in input && - typeof input.op === 'number' && - input.op in Operation && - 'd' in input && - typeof input.d === 'object' - ) { - try { - parse(PacketDataSchemas[input.op as Operation], input.d) - return true - } catch { - return false - } - } - return false -}, 'Invalid packet data') - -/** - * Schema to validate packet data for each possible operations - */ -export const PacketDataSchemas = { - [ServerOperation.Hello]: object({ - heartbeatInterval: number(), - }), - [ServerOperation.HeartbeatAck]: object({ - nextHeartbeat: number(), - }), - [ServerOperation.ParsedText]: object({ - id: string(), - labels: array( - object({ - name: string(), - confidence: special( - input => - typeof input === 'number' && input >= 0 && input <= 1 - ), - }) - ), - }), - [ServerOperation.ParsedImage]: object({ - id: string(), - text: string(), - }), - [ServerOperation.ParseTextFailed]: object({ - id: string(), - }), - [ServerOperation.ParseImageFailed]: object({ - id: string(), - }), - [ServerOperation.Disconnect]: object({ - reason: enum_(DisconnectReason), - }), - - [ClientOperation.Heartbeat]: null_(), - [ClientOperation.ParseText]: object({ - id: string(), - text: string(), - }), - [ClientOperation.ParseImage]: object({ - id: string(), - image_url: string([url()]), - }), -} as const satisfies Record< - Operation, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ObjectSchema | AnySchema | NullSchema -> - -export type Packet = { - op: TOp - d: Output<(typeof PacketDataSchemas)[TOp]> -} +import { + url, + AnySchema, + NullSchema, + ObjectSchema, + Output, + array, + enum_, + null_, + number, + object, + parse, + special, + string, + // merge +} from 'valibot' +import DisconnectReason from '../constants/DisconnectReason.js' +import { + ClientOperation, + Operation, + ServerOperation, +} from '../constants/Operation.js' + +/** + * Schema to validate packets + */ +export const PacketSchema = special(input => { + if ( + typeof input === 'object' && + input && + 'op' in input && + typeof input.op === 'number' && + input.op in Operation && + 'd' in input && + typeof input.d === 'object' + ) { + try { + parse(PacketDataSchemas[input.op as Operation], input.d) + return true + } catch { + return false + } + } + return false +}, 'Invalid packet data') + +/** + * Schema to validate packet data for each possible operations + */ +export const PacketDataSchemas = { + [ServerOperation.Hello]: object({ + heartbeatInterval: number(), + }), + [ServerOperation.HeartbeatAck]: object({ + nextHeartbeat: number(), + }), + [ServerOperation.ParsedText]: object({ + id: string(), + labels: array( + object({ + name: string(), + confidence: special( + input => + typeof input === 'number' && input >= 0 && input <= 1, + ), + }), + ), + }), + [ServerOperation.ParsedImage]: object({ + id: string(), + text: string(), + }), + [ServerOperation.ParseTextFailed]: object({ + id: string(), + }), + [ServerOperation.ParseImageFailed]: object({ + id: string(), + }), + [ServerOperation.Disconnect]: object({ + reason: enum_(DisconnectReason), + }), + + [ClientOperation.Heartbeat]: null_(), + [ClientOperation.ParseText]: object({ + id: string(), + text: string(), + }), + [ClientOperation.ParseImage]: object({ + id: string(), + image_url: string([url()]), + }), +} as const satisfies Record< + Operation, + // biome-ignore lint/suspicious/noExplicitAny: This is a schema, it's not possible to type it + ObjectSchema | AnySchema | NullSchema +> + +export type Packet = { + op: TOp + d: Output +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index b593970..8b93531 100755 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1 +1 @@ -export * from './Packet.js' +export * from './Packet.js' diff --git a/packages/shared/src/utils/guard.ts b/packages/shared/src/utils/guard.ts index 24a98e2..86ec59f 100755 --- a/packages/shared/src/utils/guard.ts +++ b/packages/shared/src/utils/guard.ts @@ -1,41 +1,41 @@ -import { Packet } from '../schemas/Packet.js' -import { - ClientOperation, - Operation, - ServerOperation, -} from '../constants/Operation.js' - -/** - * Checks whether a packet is trying to do the given operation - * @param op Operation code to check - * @param packet A packet - * @returns Whether this packet is trying to do the operation given - */ -export function packetMatchesOperation( - op: TOp, - packet: Packet -): packet is Packet { - return packet.op === op -} - -/** - * Checks whether this packet is a client packet **(this does NOT validate the data)** - * @param packet A packet - * @returns Whether this packet is a client packet - */ -export function isClientPacket( - packet: Packet -): packet is Packet { - return packet.op in ClientOperation -} - -/** - * Checks whether this packet is a server packet **(this does NOT validate the data)** - * @param packet A packet - * @returns Whether this packet is a server packet - */ -export function isServerPacket( - packet: Packet -): packet is Packet { - return packet.op in ServerOperation -} +import { + ClientOperation, + Operation, + ServerOperation, +} from '../constants/Operation.js' +import { Packet } from '../schemas/Packet.js' + +/** + * Checks whether a packet is trying to do the given operation + * @param op Operation code to check + * @param packet A packet + * @returns Whether this packet is trying to do the operation given + */ +export function packetMatchesOperation( + op: TOp, + packet: Packet, +): packet is Packet { + return packet.op === op +} + +/** + * Checks whether this packet is a client packet **(this does NOT validate the data)** + * @param packet A packet + * @returns Whether this packet is a client packet + */ +export function isClientPacket( + packet: Packet, +): packet is Packet { + return packet.op in ClientOperation +} + +/** + * Checks whether this packet is a server packet **(this does NOT validate the data)** + * @param packet A packet + * @returns Whether this packet is a server packet + */ +export function isServerPacket( + packet: Packet, +): packet is Packet { + return packet.op in ServerOperation +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 61fcb8d..47ea1c6 100755 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,3 +1,3 @@ -export * from './guard.js' -export * from './serialization.js' -export * from './string.js' +export * from './guard.js' +export * from './serialization.js' +export * from './string.js' diff --git a/packages/shared/src/utils/serialization.ts b/packages/shared/src/utils/serialization.ts index 764af14..92b274f 100755 --- a/packages/shared/src/utils/serialization.ts +++ b/packages/shared/src/utils/serialization.ts @@ -1,23 +1,23 @@ -import * as BSON from 'bson' -import { Packet, PacketSchema } from '../schemas/index.js' -import { Operation } from '../constants/index.js' -import { parse } from 'valibot' - -/** - * Compresses a packet into a buffer - * @param packet The packet to compress - * @returns A buffer of the compressed packet - */ -export function serializePacket(packet: Packet) { - return BSON.serialize(packet) -} - -/** - * Decompresses a buffer into a packet - * @param buffer The buffer to decompress - * @returns A packet - */ -export function deserializePacket(buffer: Buffer) { - const data = BSON.deserialize(buffer) - return parse(PacketSchema, data) as Packet -} +import * as BSON from 'bson' +import { parse } from 'valibot' +import { Operation } from '../constants/index.js' +import { Packet, PacketSchema } from '../schemas/index.js' + +/** + * Compresses a packet into a buffer + * @param packet The packet to compress + * @returns A buffer of the compressed packet + */ +export function serializePacket(packet: Packet) { + return BSON.serialize(packet) +} + +/** + * Decompresses a buffer into a packet + * @param buffer The buffer to decompress + * @returns A packet + */ +export function deserializePacket(buffer: Buffer) { + const data = BSON.deserialize(buffer) + return parse(PacketSchema, data) as Packet +} diff --git a/packages/shared/src/utils/string.ts b/packages/shared/src/utils/string.ts index c9d44fe..e7e805e 100755 --- a/packages/shared/src/utils/string.ts +++ b/packages/shared/src/utils/string.ts @@ -1,8 +1,8 @@ -/** - * Uncapitalizes the first letter of a string - * @param str The string to uncapitalize - * @returns The uncapitalized string - */ -export function uncapitalize(str: T): Uncapitalize { - return (str.charAt(0).toLowerCase() + str.slice(1)) as Uncapitalize -} +/** + * Uncapitalizes the first letter of a string + * @param str The string to uncapitalize + * @returns The uncapitalized string + */ +export function uncapitalize(str: T): Uncapitalize { + return (str.charAt(0).toLowerCase() + str.slice(1)) as Uncapitalize +} diff --git a/tsconfig.apis.json b/tsconfig.apis.json index 89b3d2b..ffcbb94 100755 --- a/tsconfig.apis.json +++ b/tsconfig.apis.json @@ -1,3 +1,3 @@ -{ - "extends": "./tsconfig.base.json" -} +{ + "extends": "./tsconfig.base.json" +} diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 5b6cb51..3406eb1 100755 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -1,15 +1,15 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "declaration": true, - "declarationMap": true - }, - "references": [ - { - "path": "./packages/shared" - }, - { - "path": "./packages/api" - } - ] -} +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true + }, + "references": [ + { + "path": "./packages/shared" + }, + { + "path": "./packages/api" + } + ] +} diff --git a/turbo.json b/turbo.json index cfafea5..7cacc10 100755 --- a/turbo.json +++ b/turbo.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://turbo.build/schema.json", - "pipeline": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"], - "outputMode": "errors-only" - }, - "watch": { - "dependsOn": ["^watch"], - "outputMode": "errors-only" - } - } -} +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "outputMode": "errors-only" + }, + "watch": { + "dependsOn": ["^watch"], + "outputMode": "errors-only" + } + } +}