Yet another Fluxer bot built with TypeScript and Bun
at develop 89 lines 2.7 kB view raw
1import { isCommandCtor } from "@/utils"; 2import type { CommandRegistry } from "./commandRegistry"; 3import path from "node:path"; 4import { watch, existsSync } from "node:fs"; 5import { createRequire } from "node:module"; 6import { Logger } from "tslog"; 7 8const logger = new Logger({ 9 type: "pretty", 10 name: "Watch", 11}); 12 13// Totally intentional, we want to torture Bun into force import changed/renamed files :) 14const _require = createRequire(import.meta.url); 15 16// Workaround for atomic files changes (mainly in other editors such as nvim, vscode, etc...) 17const debounceTimers = new Map<string, Timer>(); 18 19async function reloadFile( 20 registry: CommandRegistry, 21 prefix: string, 22 absPath: string, 23): Promise<void> { 24 const fileUrl = Bun.pathToFileURL(absPath).href; 25 26 try { 27 delete _require.cache[fileUrl]; 28 29 const mod = await import(fileUrl); 30 31 // Collect all valid command constructors from this file first, 32 // before touching the registry, so we know if anything actually loaded. 33 const ctors = Object.values(mod).filter(isCommandCtor); 34 35 if (ctors.length === 0) { 36 // File exists but has no @Command decorator yet — skip silently. 37 // It will be picked up automatically on the next save once the decorator is added. 38 logger.info(`Skipped ${path.basename(absPath)}: no @Command decorator found`); 39 return; 40 } 41 42 // Only unregister the old version once we know the new one is valid. 43 registry.unregisterFile(absPath); 44 45 for (const ctor of ctors) { 46 registry.register(prefix, absPath, ctor); 47 } 48 49 logger.info(`Reloaded: ${absPath}`); 50 } catch (e) { 51 logger.error(`Failed to reload ${absPath}:`, e); 52 } 53} 54 55export function watchCommands( 56 registry: CommandRegistry, 57 prefix: string, 58 dir = "src/commands", 59): void { 60 // We intentionally ignore the event type here. 61 // On Linux, editors do atomic saves (write tmp → rename into place), 62 // which means "change" never fires — only "rename" does. 63 // So we check existsSync after a short debounce instead. 64 watch(dir, { recursive: true }, (_, filename) => { 65 if (!filename?.endsWith(".ts")) return; 66 67 const absPath = path.resolve(dir, filename); 68 69 const existing = debounceTimers.get(absPath); 70 if (existing) clearTimeout(existing); 71 72 debounceTimers.set( 73 absPath, 74 setTimeout(() => { 75 debounceTimers.delete(absPath); 76 77 if (existsSync(absPath)) { 78 logger.info(`Reloading ${absPath}`); 79 reloadFile(registry, prefix, absPath); 80 } else { 81 registry.unregisterFile(absPath); 82 logger.info(`Unloaded: ${absPath}`); 83 } 84 }, 50), 85 ); 86 }); 87 88 logger.info(`Watching ${dir}/ for changes...`); 89}