Files
2026-10-22 17:07:08 +08:00

144 lines
6.1 KiB
TypeScript

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<InfoManager> {
const infos = new Map<string, BaseInfo<unknown>>();
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<unknown>): 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<unknown> | 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<unknown>)(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;
}
}