import {readdir} from 'node:fs/promises'; import type {Bot} from '@wp2508/mineflayer'; import type {BaseInfo, InfoConstructor, InfoContext, InfoI18n} from './types.ts'; export interface InfoSnapshot { readonly name: string; readonly i18n: InfoI18n; readonly value: unknown; } export interface InfoUpdate { readonly name: string; readonly value: unknown; } export interface InfoManager { get_info(name: string): InfoSnapshot | undefined; get_all_info(): InfoSnapshot[]; /** Subscribes to future changes; returns an idempotent unsubscribe function. */ on_update(callback: (event: InfoUpdate) => void): () => void; /** Safe to call repeatedly, including before the bot emits end. */ dispose(): void; } /** Scans bot/ and instance/ recursively and creates fresh modules for this bot. */ export async function create_info_manager(bot: Bot, options?: InfoContext): Promise { const infos = new Map>(); const subscriptions = new Set<() => void>(); const listeners = new Set<(event: InfoUpdate) => void>(); let disposed = false; const assert_active = () => { if (disposed) throw new Error('Info manager has been disposed'); }; const cleanup = (callback: () => void) => { try { callback(); } catch (error) { console.error('Info manager cleanup failed', error); } }; const snapshot = (info: BaseInfo): InfoSnapshot => ({ name: info.name, i18n: {...info.i18n}, value: structuredClone(info.get_info()), }); const manager: InfoManager = { get_info(name) { const info = infos.get(name); return info ? snapshot(info) : undefined; }, get_all_info() { return Array.from(infos.values(), snapshot); }, on_update(callback) { assert_active(); // Each subscription owns its cancellation, even for the same callback. const listener = (event: InfoUpdate) => callback(event); listeners.add(listener); return () => { listeners.delete(listener); }; }, dispose() { if (disposed) return; disposed = true; bot.off('end', manager.dispose); listeners.clear(); for (const unsubscribe of subscriptions) cleanup(unsubscribe); subscriptions.clear(); for (const info of infos.values()) cleanup(() => info.dispose()); infos.clear(); }, }; // Install before the first await so an end during loading also cancels creation. bot.once('end', manager.dispose); try { const directories = [new URL('./bot/', import.meta.url), new URL('./instance/', import.meta.url)]; const modules: URL[] = []; for (const directory of directories) { const entries = await readdir(directory, {withFileTypes: true}); assert_active(); entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { if (entry.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry.name)) { directories.push(new URL(`${entry.name}/`, directory)); } else if (entry.isFile() && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) { modules.push(new URL(entry.name, directory)); } } } modules.sort((a, b) => a.href.localeCompare(b.href)); for (const url of modules) { let info: BaseInfo | undefined; let unsubscribe: (() => void) | undefined; try { assert_active(); url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`); const module = await import(url.href) as {init?: unknown}; assert_active(); if (typeof module.init !== 'function') { throw new Error('Info module must export an init constructor'); } const candidate = new (module.init as InfoConstructor)(bot, options); if (!candidate || typeof candidate.dispose !== 'function') { throw new Error('Info module must implement dispose()'); } // Keep ownership locally until validation and subscription both succeed. info = candidate; assert_active(); if (typeof info.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(info.name) || !info.i18n || typeof info.i18n.lang !== 'string' || typeof info.i18n.name !== 'string' || typeof info.i18n.description !== 'string' || typeof info.get_info !== 'function' || typeof info.on_update !== 'function') { throw new Error('Info module does not implement BaseInfo'); } const name = info.name; if (infos.has(name)) throw new Error(`Duplicate info name: ${name}`); const cancel = info.on_update(value => { if (disposed) return; for (const listener of Array.from(listeners)) { if (disposed) break; if (!listeners.has(listener)) continue; try { listener({name, value: structuredClone(value)}); } catch (error) { console.error(`Info update listener failed: ${name}`, error); } } }); if (typeof cancel !== 'function') { throw new Error('Info on_update() must return an unsubscribe function'); } unsubscribe = cancel; assert_active(); infos.set(name, info); subscriptions.add(unsubscribe); } catch (error) { if (unsubscribe) cleanup(unsubscribe); if (info) cleanup(() => info!.dispose()); throw new Error(`Info module ${url.pathname} initialization failed`, {cause: error}); } } assert_active(); return manager; } catch (error) { manager.dispose(); throw error; } }