import { readFile, readdir, stat } from "node:fs/promises"; import { watch as fsWatch, type FSWatcher } from "node:fs"; import { basename, extname, resolve } from "node:path"; import { z } from "zod"; import { parse } from "yaml"; import { contentSchemas, contentDirNames, isContentKind, type ContentKind } from "../schema/index.js"; import { contentBundleSchema, type AssetEntry, type ContentBundle } from "./bundle.js"; import { ContentRegistry } from "./registry.js"; const ASSET_DIR_NAME = "assets"; const ASSET_KINDS: Record = { ".png": { kind: "image", mime: "image/png" }, ".ogg": { kind: "audio", mime: "audio/ogg" }, }; const contentKinds: ContentKind[] = Object.keys(contentSchemas) as ContentKind[]; export interface LoadItemResult { id: string; kind: string; status: "ok" | "error"; errors?: string[]; } export interface LoadReport { ok: boolean; items: LoadItemResult[]; } export function normalize(kind: string, yamlDoc: unknown): unknown { return yamlDoc; } export function validate(kind: string, json: unknown) { if (!isContentKind(kind)) { const error = new z.ZodError([ { code: z.ZodIssueCode.custom, path: [], message: `unknown content kind: ${kind}` }, ]); return { success: false as const, error }; } return contentSchemas[kind].safeParse(json); } export class ContentLoader { constructor(private readonly registry: ContentRegistry) {} async load(dir: string): Promise { const items: LoadItemResult[] = []; for (const kind of Object.keys(contentSchemas) as ContentKind[]) { items.push(...(await this.loadKind(dir, kind))); } await this.loadAssets(dir); return { ok: items.length > 0 && items.every((i) => i.status === "ok"), items }; } async loadBundle(bundle: ContentBundle): Promise { const structural = contentBundleSchema.safeParse(bundle); if (!structural.success) { throw new Error( `content bundle is not a valid nengine-bundle: ${structural.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; ")}`, ); } const items: LoadItemResult[] = []; for (const [kind, defs] of Object.entries(bundle.content)) { defs.forEach((def, index) => { if (!isContentKind(kind)) { items.push({ id: defIdOf(def, `${kind}[${index}]`), kind, status: "error", errors: [`unknown content kind: ${kind}`] }); return; } const validated = contentSchemas[kind].safeParse(def); if (!validated.success) { items.push({ id: defIdOf(def, `${kind}[${index}]`), kind, status: "error", errors: validated.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`), }); return; } this.registry.register(kind, validated.data); items.push({ id: validated.data.id, kind, status: "ok" }); }); } this.registry.assets.clear(); const index = bundle.assetsIndex ?? {}; for (const [id, entry] of Object.entries(bundle.assets ?? {})) { const meta = index[id]; this.registry.assets.set(id, meta ? { ...entry, size: meta.size, ...(meta.file !== undefined ? { file: meta.file } : {}) } : entry); } for (const [id, meta] of Object.entries(index)) { if (this.registry.assets.has(id)) continue; this.registry.assets.set(id, { kind: meta.kind, mime: meta.mime, size: meta.size, ...(meta.file !== undefined ? { file: meta.file } : {}) }); } return { ok: items.length > 0 && items.every((i) => i.status === "ok"), items }; } watch(dir: string, opts?: { pollIntervalMs?: number }): () => void { let timer: ReturnType | undefined; let closed = false; let watcher: FSWatcher | undefined; try { watcher = fsWatch(dir, { recursive: true }, (_event, filename) => { if (closed || !filename) return; const rel = filename.toString(); const kind = this.kindForPath(rel); const isAsset = isAssetPath(rel); if (!kind && !isAsset) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = undefined; if (kind) void this.reloadKind(dir, kind); if (isAsset) void this.reloadAssets(dir); }, 50); }); } catch { watcher = undefined; } // Fallback polling: on platforms where fs watch events are unreliable, // compare directory mtimes / asset listings periodically. const pollInterval = typeof opts?.pollIntervalMs === "number" && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : 250; let lastAssetKeys = new Set(this.registry.assets.keys()); let lastKindsSnapshot = new Map(); void Promise.resolve().then(async () => { for (const k of contentKinds) { lastKindsSnapshot.set(k, await this.kindMtime(dir, k)); } }); const pollHandle = setInterval(async () => { if (closed) return; try { // Asset diff: compare registry size against actual files. // Use the asset index builder for accuracy. const assetIndex = await this.buildAssetIndex(dir); const currentKeys = new Set(Object.keys(assetIndex)); let assetChanged = currentKeys.size !== lastAssetKeys.size; if (!assetChanged) { for (const k of currentKeys) { if (!lastAssetKeys.has(k)) { assetChanged = true; break; } } if (!assetChanged) { for (const k of lastAssetKeys) { if (!currentKeys.has(k)) { assetChanged = true; break; } } } } if (assetChanged) { lastAssetKeys = currentKeys; void this.reloadAssets(dir); } for (const k of contentKinds) { const mtime = await this.kindMtime(dir, k); const prev = lastKindsSnapshot.get(k) ?? 0; if (mtime !== prev) { lastKindsSnapshot.set(k, mtime); void this.reloadKind(dir, k); } } } catch { // ignore polling errors } }, pollInterval); return () => { closed = true; if (timer) clearTimeout(timer); clearInterval(pollHandle); try { watcher?.close(); } catch {} }; } private async loadKind(dir: string, kind: ContentKind): Promise { const kindDir = resolve(dir, contentDirNames[kind]); const singleFile = resolve(dir, `${kind}.yaml`); let files: string[] = []; let isDir = false; try { isDir = (await stat(kindDir)).isDirectory(); } catch { isDir = false; } if (isDir) { const entries = await readdir(kindDir); files = entries .filter((e) => e.endsWith(".yaml") || e.endsWith(".yml")) .map((e) => resolve(kindDir, e)) .sort(); } else { try { await stat(singleFile); files = [singleFile]; } catch { files = []; } } const results: LoadItemResult[] = []; for (const file of files) { results.push(await this.loadFile(kind, file)); } return results; } private async loadFile(kind: ContentKind, file: string): Promise { const fallbackId = basename(file, extname(file)); let text: string; try { text = await readFile(file, "utf8"); } catch (err) { return { id: fallbackId, kind, status: "error", errors: [messageOf(err)] }; } let doc: unknown; try { doc = parse(text); } catch (err) { return { id: fallbackId, kind, status: "error", errors: [messageOf(err)] }; } const validated = validate(kind, normalize(kind, doc)); if (!validated.success) { return { id: typeof (doc as { id?: unknown } | null)?.id === "string" ? (doc as { id: string }).id : fallbackId, kind, status: "error", errors: validated.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`), }; } this.registry.register(kind, validated.data); return { id: validated.data.id, kind, status: "ok" }; } private async reloadKind(dir: string, kind: ContentKind): Promise { for (const id of this.registry.ids(kind)) { this.registry.remove(kind, id); } await this.loadKind(dir, kind); } private async loadAssets(dir: string): Promise { const assetDir = resolve(dir, ASSET_DIR_NAME); let files: string[] = []; try { if (!(await stat(assetDir)).isDirectory()) return; files = (await readdir(assetDir)).filter((e) => ASSET_KINDS[extname(e).toLowerCase()]).sort(); } catch { return; } const next = new Map(); for (const file of files) { const info = ASSET_KINDS[extname(file).toLowerCase()]; if (!info) continue; try { const data = await readFile(resolve(assetDir, file)); next.set(basename(file, extname(file)), { kind: info.kind, mime: info.mime, data: data.toString("base64") }); } catch { // best-effort: an unreadable asset file is skipped, not fatal } } this.registry.assets.clear(); for (const [id, entry] of next) { this.registry.assets.set(id, entry); } } private async reloadAssets(dir: string): Promise { await this.loadAssets(dir); } private async buildAssetIndex(dir: string): Promise> { const assetDir = resolve(dir, ASSET_DIR_NAME); const index: Record = {}; try { const statDir = await stat(assetDir); if (!statDir.isDirectory()) return index; const files = await readdir(assetDir); for (const file of files) { const ext = extname(file).toLowerCase(); const info = ASSET_KINDS[ext]; if (!info) continue; const id = basename(file, ext); let size = 0; try { size = (await stat(resolve(assetDir, file))).size; } catch { size = -1; } index[id] = { kind: info.kind, size }; } } catch { // ignore } return index; } private async kindMtime(dir: string, kind: ContentKind): Promise { const singleFile = resolve(dir, `${kind}.yaml`); const kindDir = resolve(dir, contentDirNames[kind]); let latest = 0; try { latest = Math.max(latest, (await stat(singleFile)).mtimeMs); } catch { // ignore } try { const kindStat = await stat(kindDir); if (kindStat.isDirectory()) { latest = Math.max(latest, kindStat.mtimeMs); const entries = await readdir(kindDir); for (const e of entries) { if (!e.endsWith(".yaml") && !e.endsWith(".yml")) continue; try { latest = Math.max(latest, (await stat(resolve(kindDir, e))).mtimeMs); } catch { // ignore } } } } catch { // ignore } return latest; } private kindForPath(rel: string): ContentKind | undefined { for (const kind of Object.keys(contentSchemas) as ContentKind[]) { if (rel === `${kind}.yaml` || rel === `${kind}.yml` || rel.startsWith(`${contentDirNames[kind]}/`)) { return kind; } } return undefined; } } function isAssetPath(rel: string): boolean { return rel.startsWith(`${ASSET_DIR_NAME}/`); } function messageOf(err: unknown): string { return err instanceof Error ? err.message : String(err); } function defIdOf(def: unknown, fallback: string): string { return typeof (def as { id?: unknown } | null)?.id === "string" ? (def as { id: string }).id : fallback; }