chore: initial commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user