Yet another Fluxer bot built with TypeScript and Bun
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}