| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- 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<string, { kind: AssetEntry["kind"]; mime: string }> = {
- ".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<LoadReport> {
- 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<LoadReport> {
- 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<typeof setTimeout> | 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<ContentKind, number>();
- 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<LoadItemResult[]> {
- 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<LoadItemResult> {
- 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<void> {
- for (const id of this.registry.ids(kind)) {
- this.registry.remove(kind, id);
- }
- await this.loadKind(dir, kind);
- }
- private async loadAssets(dir: string): Promise<void> {
- 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<string, AssetEntry>();
- 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<void> {
- await this.loadAssets(dir);
- }
- private async buildAssetIndex(dir: string): Promise<Record<string, { kind: AssetEntry["kind"]; size: number }>> {
- const assetDir = resolve(dir, ASSET_DIR_NAME);
- const index: Record<string, { kind: AssetEntry["kind"]; size: number }> = {};
- 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<number> {
- 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;
- }
|