From b073be1933d28038327fa16a52fa0bdcb9cf9992 Mon Sep 17 00:00:00 2001 From: barthofu Date: Fri, 8 Dec 2023 18:41:34 +0000 Subject: [PATCH] feat(#131): hmr on `commands` and `events` files --- mikro-orm.config.ts | 2 + package.json | 6 +-- src/main.ts | 92 ++++++++++++++++++++++++++++++++++++---- src/services/Database.ts | 26 ++++++------ src/services/Logger.ts | 34 ++++++++------- src/services/Store.ts | 2 + 6 files changed, 122 insertions(+), 40 deletions(-) diff --git a/mikro-orm.config.ts b/mikro-orm.config.ts index b2638f21..08de317a 100644 --- a/mikro-orm.config.ts +++ b/mikro-orm.config.ts @@ -1,3 +1,5 @@ +// @ts-nocheck + import { mikroORMConfig } from "./src/configs/database" import * as entities from "@entities" import { PluginsManager } from "@services" diff --git a/package.json b/package.json index 740a3310..12391a89 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "install:plugins": "installoop --rootDir=./build/plugins", "type:check": "tsc --pretty --skipLibCheck --noEmit", "start": "cross-env NODE_ENV=production node build/main.js", - "dev": "cross-env NODE_ENV=development nodemon --exec node -r ts-node/register/transpile-only src/main.ts", - "dev:start": "cross-env NODE_ENV=production node -r ts-node/register/transpile-only src/main.ts", + "dev": "cross-env NODE_ENV=development nodemon --exec ts-node --transpile-only src/main.ts", "i18n": "typesafe-i18n", "migration:create": "npx mikro-orm migration:create", "migration:up": "npx mikro-orm migration:up", @@ -121,7 +120,8 @@ }, "nodemonConfig": { "ignore": [ - "src/i18n/**/!(i18n-types.ts)" + "src/i18n/**/!(i18n-types.ts)", + "src/{events,commands}/**/*.{ts,js}" ] }, "tscord": { diff --git a/src/main.ts b/src/main.ts index a938d40c..e3dc0674 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,84 @@ import 'dotenv/config' import 'reflect-metadata' -import { importx } from "@discordx/importer" +import { resolve } from "@discordx/importer" +import chokidar from 'chokidar' import discordLogs from "discord-logs" -import { Client, DIService, tsyringeDependencyRegistryEngine } from "discordx" +import { Client, DIService, MetadataStorage, tsyringeDependencyRegistryEngine } from "discordx" import { container } from "tsyringe" import { Server } from "@api/server" import { apiConfig, generalConfig, websocketConfig } from "@configs" import { NoBotTokenError } from "@errors" +import { RequestContext } from '@mikro-orm/core' import { Database, ErrorHandler, EventManager, ImagesUpload, Logger, PluginsManager, Store, WebSocket } from "@services" import { initDataTable, resolveDependency } from "@utils/functions" +import chalk from 'chalk' import { clientConfig } from "./client" -import { RequestContext } from '@mikro-orm/core' -async function run() { +const importPattern = __dirname + "/{events,commands}/**/*.{ts,js}" + +/** + * Import files + * @param path glob pattern + */ +async function loadFiles(path: string): Promise { + const files = await resolve(path) + await Promise.all( + files.map((file) => { + const newFileName = file.replace('file://', '') + delete require.cache[newFileName] + import(newFileName) + }) + ) +} + +/** + * Hot reload + */ +async function reload(client: Client) { + + const store = await resolveDependency(Store) + store.set('botHasBeenReloaded', true) + + const logger = await resolveDependency(Logger) + console.log('\n') + logger.startSpinner('Hot reloading...') + + // Remove events + client.removeEvents() + + // cleanup + MetadataStorage.clear() + DIService.engine.clearAllServices() + + // transfer store instance to the new container in order to keep the same states + container.registerInstance(Store, store) + + // reload files + await loadFiles(importPattern) + + // rebuild + await MetadataStorage.instance.build() + await client.initApplicationCommands() + client.initEvents() + + // re-init services + + // plugins + const pluginManager = await resolveDependency(PluginsManager) + await pluginManager.loadPlugins() + // await pluginManager.execMains() # TODO: need this? + + // database + const db = await resolveDependency(Database) + await db.initialize(false) + + logger.log(chalk.whiteBright('Hot reloaded')) +} + +async function init() { - // init logger, pluginsmanager and error handler const logger = await resolveDependency(Logger) // init error handler @@ -24,8 +86,6 @@ async function run() { // init plugins const pluginManager = await resolveDependency(PluginsManager) - - // load plugins and import translations await pluginManager.loadPlugins() await pluginManager.syncTranslations() @@ -46,12 +106,14 @@ async function run() { container.registerInstance(Client, client) // import all the commands and events - await importx(__dirname + "/{events,commands}/**/*.{ts,js}") + await loadFiles(importPattern) await pluginManager.importCommands() await pluginManager.importEvents() RequestContext.create(db.orm.em, async () => { + const watcher = chokidar.watch(importPattern) + // init the data table if it doesn't exist await initDataTable() @@ -66,6 +128,18 @@ async function run() { client.login(process.env.BOT_TOKEN) .then(async () => { + if (process.env.NODE_ENV === 'development') { + + // reload commands and events when a file changes + watcher.on('change', () => reload(client)) + + // reload commands and events when a file is added + watcher.on('add', () => reload(client)) + + // reload commands and events when a file is deleted + watcher.on('unlink', () => reload(client)) + } + // start the api server if (apiConfig.enabled) { const server = await resolveDependency(Server) @@ -108,4 +182,4 @@ async function run() { } -run() \ No newline at end of file +init() \ No newline at end of file diff --git a/src/services/Database.ts b/src/services/Database.ts index c230a147..b30b67ba 100644 --- a/src/services/Database.ts +++ b/src/services/Database.ts @@ -17,9 +17,9 @@ export class Database { constructor( @inject(delay(() => Logger)) private logger: Logger - ) { } + ) {} - async initialize() { + async initialize(migrate = true) { const pluginsManager = await resolveDependency(PluginsManager) @@ -32,17 +32,19 @@ export class Database { // initialize the ORM using the configuration exported in `mikro-orm.config.ts` this._orm = await MikroORM.init(config) - const migrator = this._orm.getMigrator() - - // create migration if no one is present in the migrations folder - const pendingMigrations = await migrator.getPendingMigrations() - const executedMigrations = await migrator.getExecutedMigrations() - if (pendingMigrations.length === 0 && executedMigrations.length === 0) { - await migrator.createInitialMigration() + if (migrate) { + const migrator = this._orm.getMigrator() + + // create migration if no one is present in the migrations folder + const pendingMigrations = await migrator.getPendingMigrations() + const executedMigrations = await migrator.getExecutedMigrations() + if (pendingMigrations.length === 0 && executedMigrations.length === 0) { + await migrator.createInitialMigration() + } + + // migrate to the latest migration + await this._orm.getMigrator().up() } - - // migrate to the latest migration - await this._orm.getMigrator().up() } async refreshConnection() { diff --git a/src/services/Logger.ts b/src/services/Logger.ts index ab86a8b7..209ee3ea 100644 --- a/src/services/Logger.ts +++ b/src/services/Logger.ts @@ -10,25 +10,13 @@ import { delay, inject, singleton } from "tsyringe" import * as controllers from "@api/controllers" import { apiConfig, logsConfig } from "@configs" -import { Pastebin, PluginsManager, Scheduler, WebSocket } from "@services" +import { Pastebin, PluginsManager, Scheduler, Store, WebSocket } from "@services" import { fileOrDirectoryExists, formatDate, getTypeOfInteraction, numberAlign, oneLine, resolveAction, resolveChannel, resolveDependency, resolveGuild, resolveUser, validString } from "@utils/functions" +const defaultConsole = { ...console } @singleton() export class Logger { - constructor( - @inject(delay(() => Client)) private client: Client, - @inject(delay(() => Scheduler)) private scheduler: Scheduler, - @inject(delay(() => WebSocket)) private ws: WebSocket, - @inject(delay(() => Pastebin)) private pastebin: Pastebin, - @inject(delay(() => PluginsManager)) private pluginsManager: PluginsManager - ) { - this.defaultConsole = { ...console } - console.info = (...args) => this.log(args.join(", "), 'info') - console.warn = (...args) => this.log(args.join(", "), 'warn') - console.error = (...args) => this.log(args.join(", "), 'error') - } - private readonly logPath: string = `${__dirname}/../../logs` private readonly levels = ['info', 'warn', 'error'] as const private embedLevelBuilder = { @@ -45,7 +33,21 @@ export class Logger { "MODAL_SUBMIT_INTERACTION": "Modal submit", } private spinner = ora() - private defaultConsole: typeof console + + constructor( + @inject(delay(() => Client)) private client: Client, + @inject(delay(() => Scheduler)) private scheduler: Scheduler, + @inject(delay(() => Store)) private store: Store, + @inject(delay(() => WebSocket)) private ws: WebSocket, + @inject(delay(() => Pastebin)) private pastebin: Pastebin, + @inject(delay(() => PluginsManager)) private pluginsManager: PluginsManager + ) { + if (!this.store.get('botHasBeenReloaded')) { + console.info = (...args) => this.log(args.join(", "), 'info') + console.warn = (...args) => this.log(args.join(", "), 'warn') + console.error = (...args) => this.log(args.join(", "), 'error') + } + } // ================================= // ======== Output Providers ======= @@ -66,7 +68,7 @@ export class Logger { let templatedMessage = ignoreTemplate ? message : `${level} [${chalk.dim.gray(formatDate(new Date()))}] ${message}` if (level === 'error') templatedMessage = chalk.red(templatedMessage) - this.defaultConsole[level](templatedMessage) + defaultConsole[level](templatedMessage) this.websocket(level, message) } diff --git a/src/services/Store.ts b/src/services/Store.ts index d3c20316..29596074 100644 --- a/src/services/Store.ts +++ b/src/services/Store.ts @@ -5,6 +5,7 @@ import { singleton } from "tsyringe" interface State { authorizedAPITokens: string[] + botHasBeenReloaded: boolean ready: { bot: boolean | null api: boolean | null @@ -15,6 +16,7 @@ interface State { const initialState: State = { authorizedAPITokens: [], + botHasBeenReloaded: false, ready: { bot: false, api: apiConfig.enabled ? false : null,