loader.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { readFile, readdir, stat } from "node:fs/promises";
  2. import { watch as fsWatch, type FSWatcher } from "node:fs";
  3. import { basename, extname, resolve } from "node:path";
  4. import { z } from "zod";
  5. import { parse } from "yaml";
  6. import { contentSchemas, contentDirNames, isContentKind, type ContentKind } from "../schema/index.js";
  7. import { contentBundleSchema, type AssetEntry, type ContentBundle } from "./bundle.js";
  8. import { ContentRegistry } from "./registry.js";
  9. const ASSET_DIR_NAME = "assets";
  10. const ASSET_KINDS: Record<string, { kind: AssetEntry["kind"]; mime: string }> = {
  11. ".png": { kind: "image", mime: "image/png" },
  12. ".ogg": { kind: "audio", mime: "audio/ogg" },
  13. };
  14. const contentKinds: ContentKind[] = Object.keys(contentSchemas) as ContentKind[];
  15. export interface LoadItemResult {
  16. id: string;
  17. kind: string;
  18. status: "ok" | "error";
  19. errors?: string[];
  20. }
  21. export interface LoadReport {
  22. ok: boolean;
  23. items: LoadItemResult[];
  24. }
  25. export function normalize(kind: string, yamlDoc: unknown): unknown {
  26. return yamlDoc;
  27. }
  28. export function validate(kind: string, json: unknown) {
  29. if (!isContentKind(kind)) {
  30. const error = new z.ZodError([
  31. { code: z.ZodIssueCode.custom, path: [], message: `unknown content kind: ${kind}` },
  32. ]);
  33. return { success: false as const, error };
  34. }
  35. return contentSchemas[kind].safeParse(json);
  36. }
  37. export class ContentLoader {
  38. constructor(private readonly registry: ContentRegistry) {}
  39. async load(dir: string): Promise<LoadReport> {
  40. const items: LoadItemResult[] = [];
  41. for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
  42. items.push(...(await this.loadKind(dir, kind)));
  43. }
  44. await this.loadAssets(dir);
  45. return { ok: items.length > 0 && items.every((i) => i.status === "ok"), items };
  46. }
  47. async loadBundle(bundle: ContentBundle): Promise<LoadReport> {
  48. const structural = contentBundleSchema.safeParse(bundle);
  49. if (!structural.success) {
  50. throw new Error(
  51. `content bundle is not a valid nengine-bundle: ${structural.error.issues
  52. .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
  53. .join("; ")}`,
  54. );
  55. }
  56. const items: LoadItemResult[] = [];
  57. for (const [kind, defs] of Object.entries(bundle.content)) {
  58. defs.forEach((def, index) => {
  59. if (!isContentKind(kind)) {
  60. items.push({ id: defIdOf(def, `${kind}[${index}]`), kind, status: "error", errors: [`unknown content kind: ${kind}`] });
  61. return;
  62. }
  63. const validated = contentSchemas[kind].safeParse(def);
  64. if (!validated.success) {
  65. items.push({
  66. id: defIdOf(def, `${kind}[${index}]`),
  67. kind,
  68. status: "error",
  69. errors: validated.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`),
  70. });
  71. return;
  72. }
  73. this.registry.register(kind, validated.data);
  74. items.push({ id: validated.data.id, kind, status: "ok" });
  75. });
  76. }
  77. this.registry.assets.clear();
  78. const index = bundle.assetsIndex ?? {};
  79. for (const [id, entry] of Object.entries(bundle.assets ?? {})) {
  80. const meta = index[id];
  81. this.registry.assets.set(id, meta ? { ...entry, size: meta.size, ...(meta.file !== undefined ? { file: meta.file } : {}) } : entry);
  82. }
  83. for (const [id, meta] of Object.entries(index)) {
  84. if (this.registry.assets.has(id)) continue;
  85. this.registry.assets.set(id, { kind: meta.kind, mime: meta.mime, size: meta.size, ...(meta.file !== undefined ? { file: meta.file } : {}) });
  86. }
  87. return { ok: items.length > 0 && items.every((i) => i.status === "ok"), items };
  88. }
  89. watch(dir: string, opts?: { pollIntervalMs?: number }): () => void {
  90. let timer: ReturnType<typeof setTimeout> | undefined;
  91. let closed = false;
  92. let watcher: FSWatcher | undefined;
  93. try {
  94. watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
  95. if (closed || !filename) return;
  96. const rel = filename.toString();
  97. const kind = this.kindForPath(rel);
  98. const isAsset = isAssetPath(rel);
  99. if (!kind && !isAsset) return;
  100. if (timer) clearTimeout(timer);
  101. timer = setTimeout(() => {
  102. timer = undefined;
  103. if (kind) void this.reloadKind(dir, kind);
  104. if (isAsset) void this.reloadAssets(dir);
  105. }, 50);
  106. });
  107. } catch {
  108. watcher = undefined;
  109. }
  110. // Fallback polling: on platforms where fs watch events are unreliable,
  111. // compare directory mtimes / asset listings periodically.
  112. const pollInterval = typeof opts?.pollIntervalMs === "number" && opts.pollIntervalMs > 0
  113. ? opts.pollIntervalMs
  114. : 250;
  115. let lastAssetKeys = new Set(this.registry.assets.keys());
  116. let lastKindsSnapshot = new Map<ContentKind, number>();
  117. void Promise.resolve().then(async () => {
  118. for (const k of contentKinds) {
  119. lastKindsSnapshot.set(k, await this.kindMtime(dir, k));
  120. }
  121. });
  122. const pollHandle = setInterval(async () => {
  123. if (closed) return;
  124. try {
  125. // Asset diff: compare registry size against actual files.
  126. // Use the asset index builder for accuracy.
  127. const assetIndex = await this.buildAssetIndex(dir);
  128. const currentKeys = new Set(Object.keys(assetIndex));
  129. let assetChanged = currentKeys.size !== lastAssetKeys.size;
  130. if (!assetChanged) {
  131. for (const k of currentKeys) {
  132. if (!lastAssetKeys.has(k)) { assetChanged = true; break; }
  133. }
  134. if (!assetChanged) {
  135. for (const k of lastAssetKeys) {
  136. if (!currentKeys.has(k)) { assetChanged = true; break; }
  137. }
  138. }
  139. }
  140. if (assetChanged) {
  141. lastAssetKeys = currentKeys;
  142. void this.reloadAssets(dir);
  143. }
  144. for (const k of contentKinds) {
  145. const mtime = await this.kindMtime(dir, k);
  146. const prev = lastKindsSnapshot.get(k) ?? 0;
  147. if (mtime !== prev) {
  148. lastKindsSnapshot.set(k, mtime);
  149. void this.reloadKind(dir, k);
  150. }
  151. }
  152. } catch {
  153. // ignore polling errors
  154. }
  155. }, pollInterval);
  156. return () => {
  157. closed = true;
  158. if (timer) clearTimeout(timer);
  159. clearInterval(pollHandle);
  160. try { watcher?.close(); } catch {}
  161. };
  162. }
  163. private async loadKind(dir: string, kind: ContentKind): Promise<LoadItemResult[]> {
  164. const kindDir = resolve(dir, contentDirNames[kind]);
  165. const singleFile = resolve(dir, `${kind}.yaml`);
  166. let files: string[] = [];
  167. let isDir = false;
  168. try {
  169. isDir = (await stat(kindDir)).isDirectory();
  170. } catch {
  171. isDir = false;
  172. }
  173. if (isDir) {
  174. const entries = await readdir(kindDir);
  175. files = entries
  176. .filter((e) => e.endsWith(".yaml") || e.endsWith(".yml"))
  177. .map((e) => resolve(kindDir, e))
  178. .sort();
  179. } else {
  180. try {
  181. await stat(singleFile);
  182. files = [singleFile];
  183. } catch {
  184. files = [];
  185. }
  186. }
  187. const results: LoadItemResult[] = [];
  188. for (const file of files) {
  189. results.push(await this.loadFile(kind, file));
  190. }
  191. return results;
  192. }
  193. private async loadFile(kind: ContentKind, file: string): Promise<LoadItemResult> {
  194. const fallbackId = basename(file, extname(file));
  195. let text: string;
  196. try {
  197. text = await readFile(file, "utf8");
  198. } catch (err) {
  199. return { id: fallbackId, kind, status: "error", errors: [messageOf(err)] };
  200. }
  201. let doc: unknown;
  202. try {
  203. doc = parse(text);
  204. } catch (err) {
  205. return { id: fallbackId, kind, status: "error", errors: [messageOf(err)] };
  206. }
  207. const validated = validate(kind, normalize(kind, doc));
  208. if (!validated.success) {
  209. return {
  210. id: typeof (doc as { id?: unknown } | null)?.id === "string" ? (doc as { id: string }).id : fallbackId,
  211. kind,
  212. status: "error",
  213. errors: validated.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`),
  214. };
  215. }
  216. this.registry.register(kind, validated.data);
  217. return { id: validated.data.id, kind, status: "ok" };
  218. }
  219. private async reloadKind(dir: string, kind: ContentKind): Promise<void> {
  220. for (const id of this.registry.ids(kind)) {
  221. this.registry.remove(kind, id);
  222. }
  223. await this.loadKind(dir, kind);
  224. }
  225. private async loadAssets(dir: string): Promise<void> {
  226. const assetDir = resolve(dir, ASSET_DIR_NAME);
  227. let files: string[] = [];
  228. try {
  229. if (!(await stat(assetDir)).isDirectory()) return;
  230. files = (await readdir(assetDir)).filter((e) => ASSET_KINDS[extname(e).toLowerCase()]).sort();
  231. } catch {
  232. return;
  233. }
  234. const next = new Map<string, AssetEntry>();
  235. for (const file of files) {
  236. const info = ASSET_KINDS[extname(file).toLowerCase()];
  237. if (!info) continue;
  238. try {
  239. const data = await readFile(resolve(assetDir, file));
  240. next.set(basename(file, extname(file)), { kind: info.kind, mime: info.mime, data: data.toString("base64") });
  241. } catch {
  242. // best-effort: an unreadable asset file is skipped, not fatal
  243. }
  244. }
  245. this.registry.assets.clear();
  246. for (const [id, entry] of next) {
  247. this.registry.assets.set(id, entry);
  248. }
  249. }
  250. private async reloadAssets(dir: string): Promise<void> {
  251. await this.loadAssets(dir);
  252. }
  253. private async buildAssetIndex(dir: string): Promise<Record<string, { kind: AssetEntry["kind"]; size: number }>> {
  254. const assetDir = resolve(dir, ASSET_DIR_NAME);
  255. const index: Record<string, { kind: AssetEntry["kind"]; size: number }> = {};
  256. try {
  257. const statDir = await stat(assetDir);
  258. if (!statDir.isDirectory()) return index;
  259. const files = await readdir(assetDir);
  260. for (const file of files) {
  261. const ext = extname(file).toLowerCase();
  262. const info = ASSET_KINDS[ext];
  263. if (!info) continue;
  264. const id = basename(file, ext);
  265. let size = 0;
  266. try {
  267. size = (await stat(resolve(assetDir, file))).size;
  268. } catch {
  269. size = -1;
  270. }
  271. index[id] = { kind: info.kind, size };
  272. }
  273. } catch {
  274. // ignore
  275. }
  276. return index;
  277. }
  278. private async kindMtime(dir: string, kind: ContentKind): Promise<number> {
  279. const singleFile = resolve(dir, `${kind}.yaml`);
  280. const kindDir = resolve(dir, contentDirNames[kind]);
  281. let latest = 0;
  282. try {
  283. latest = Math.max(latest, (await stat(singleFile)).mtimeMs);
  284. } catch {
  285. // ignore
  286. }
  287. try {
  288. const kindStat = await stat(kindDir);
  289. if (kindStat.isDirectory()) {
  290. latest = Math.max(latest, kindStat.mtimeMs);
  291. const entries = await readdir(kindDir);
  292. for (const e of entries) {
  293. if (!e.endsWith(".yaml") && !e.endsWith(".yml")) continue;
  294. try {
  295. latest = Math.max(latest, (await stat(resolve(kindDir, e))).mtimeMs);
  296. } catch {
  297. // ignore
  298. }
  299. }
  300. }
  301. } catch {
  302. // ignore
  303. }
  304. return latest;
  305. }
  306. private kindForPath(rel: string): ContentKind | undefined {
  307. for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
  308. if (rel === `${kind}.yaml` || rel === `${kind}.yml` || rel.startsWith(`${contentDirNames[kind]}/`)) {
  309. return kind;
  310. }
  311. }
  312. return undefined;
  313. }
  314. }
  315. function isAssetPath(rel: string): boolean {
  316. return rel.startsWith(`${ASSET_DIR_NAME}/`);
  317. }
  318. function messageOf(err: unknown): string {
  319. return err instanceof Error ? err.message : String(err);
  320. }
  321. function defIdOf(def: unknown, fallback: string): string {
  322. return typeof (def as { id?: unknown } | null)?.id === "string" ? (def as { id: string }).id : fallback;
  323. }