import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; // 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포 // 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다. export function verifyBuild(directory) { // 번들에 실제 포함되는 전이 의존성도 잠금 파일에서 찾는다. 프로젝트의 // 비공개 라이선스와 별개로 설치된 MIT 전문·버전을 사본 세 곳에서 보존한다. try { const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8"); const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); for (const [path, pkg] of Object.entries(lock.packages)) { if (!path || pkg.dev) continue; const name = path.split("node_modules/").at(-1); const license = readFileSync(`${path}/LICENSE`, "utf8").trim(); if (!notice.includes(`## ${name} ${pkg.version}\n\n${license}`)) throw new Error(`${name} ${pkg.version}의 전문이 없습니다.`); } for (const path of [ "public/THIRD_PARTY_NOTICES.md", resolve(directory, "THIRD_PARTY_NOTICES.md"), ]) { if (readFileSync(path, "utf8") !== notice) throw new Error(`${path}가 원본과 다릅니다.`); } } catch (cause) { throw new Error("런타임 의존성 notice 누락 또는 불일치", { cause }); } const manifest = JSON.parse( readFileSync("public/audio/manifest.json", "utf8"), ); const builtManifest = JSON.parse( readFileSync(resolve(directory, "audio/manifest.json"), "utf8"), ); if (JSON.stringify(manifest) !== JSON.stringify(builtManifest)) throw new Error("빌드 음성 목록이 원본과 다릅니다."); for (const entry of manifest.files) { const bytes = readFileSync(resolve(directory, "audio", entry.file)); if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) throw new Error(`빌드 자산 해시 불일치: ${entry.file}`); } return manifest.files.length; } if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href ) { console.log( `빌드 음성 자산 ${verifyBuild("dist")}개 및 런타임 notice 전문 확인`, ); }