import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const distSpecs = [ { name: '2D', root: resolve(projectRoot, 'apps/viewer-2d-sample/dist'), maxBytes: 4_500_000, required: [/^assets\/acadrust_dwg_bg-.+\.wasm$/], }, { name: '3D', root: resolve(projectRoot, 'apps/viewer-3d/dist'), maxBytes: 9_000_000, required: [ /^assets\/draco_decoder-.+\.wasm$/, /^assets\/basis_transcoder-.+\.wasm$/, /^web-ifc\/web-ifc\.wasm$/, ], }, ]; function listFiles(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = resolve(directory, entry.name); return entry.isDirectory() ? listFiles(path) : [path]; }); } function toRelative(root, path) { return relative(root, path).split(sep).join('/'); } const failures = []; const results = []; for (const spec of distSpecs) { if (!existsSync(spec.root)) { failures.push(`${spec.name} production dist가 없습니다. 먼저 npm run build를 실행하세요.`); continue; } const files = listFiles(spec.root); const relativeFiles = files.map((path) => toRelative(spec.root, path)); const totalBytes = files.reduce((sum, path) => sum + statSync(path).size, 0); for (const path of relativeFiles) { if (path.endsWith('.map')) { failures.push(`${spec.name} production source map 포함: ${path}`); } if (spec.name === '3D' && path === 'web-ifc/web-ifc-mt.wasm') { failures.push(`미사용 multi-thread IFC WASM 포함: ${path}`); } if ( spec.name === '3D' && (path.startsWith('draco/') || path.startsWith('basis/')) ) { failures.push(`Three.js bundled decoder의 public 중복 포함: ${path}`); } } for (const pattern of spec.required) { if (!relativeFiles.some((path) => pattern.test(path))) { failures.push(`${spec.name} 필수 runtime artifact 누락: ${pattern}`); } } const hashes = new Map(); for (const path of files) { if (statSync(path).size < 50_000) continue; const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); const paths = hashes.get(hash) ?? []; paths.push(toRelative(spec.root, path)); hashes.set(hash, paths); } const duplicates = [...hashes.values()].filter((paths) => paths.length > 1); for (const paths of duplicates) { failures.push(`${spec.name} 50 kB 이상 중복 artifact: ${paths.join(', ')}`); } if (totalBytes > spec.maxBytes) { failures.push( `${spec.name} production dist budget 초과: ${totalBytes} bytes > ${spec.maxBytes} bytes`, ); } results.push({ name: spec.name, files: relativeFiles.length, totalBytes, duplicates: duplicates.length, }); } if (failures.length > 0) { console.error(failures.map((failure) => `- ${failure}`).join('\n')); process.exitCode = 1; } else { console.log( results .map( ({ name, files, totalBytes, duplicates }) => `[artifact-check] ${name}: ${files} files, ${totalBytes} bytes, duplicate ${duplicates}`, ) .join('\n'), ); }