import { createWriteStream } from 'node:fs' import { rm } from 'node:fs/promises' import { ZipArchive } from 'archiver' /** * ZIP 엔트리 경로를 안전하게 정리한다. * @param {string} pathValue - 엔트리 경로 * @returns {string} 정리된 경로 */ const normalizeZipPath = (pathValue) => String(pathValue || '') .replace(/\\/g, '/') .replace(/^\/+/g, '') .split('/') .filter((part) => part && part !== '.' && part !== '..') .join('/') /** * ZIP 파일을 디스크로 스트리밍 생성한다. * 이미 압축된 이미지·영상·첨부 파일은 원본 바이트를 유지한 채 store 방식으로 담는다. * @param {string} outputPath - 생성할 ZIP 절대 경로 * @param {Array<{ path: string, data?: Buffer|string, sourcePath?: string, store?: boolean }>} files - ZIP 엔트리 * @returns {Promise} */ export const writeZipFile = async (outputPath, files) => { await new Promise((resolve, reject) => { const output = createWriteStream(outputPath, { flags: 'wx' }) const archive = new ZipArchive({ zlib: { level: 6 }, forceZip64: true }) let settled = false /** * ZIP 스트림을 한 번만 종료한다. * @param {Error|null} error - 실패 오류 * @returns {void} */ const finish = (error = null) => { if (settled) { return } settled = true if (error) { reject(error) return } resolve() } output.on('close', () => finish()) output.on('error', (error) => finish(error)) archive.on('error', (error) => finish(error)) archive.on('warning', (error) => { if (error?.code !== 'ENOENT') { finish(error) } }) archive.pipe(output) for (const file of files) { const path = normalizeZipPath(file.path) if (!path) { continue } if (file.sourcePath) { archive.file(file.sourcePath, { name: path, store: file.store !== false }) continue } const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data || ''), 'utf8') archive.append(data, { name: path, store: Boolean(file.store) }) } archive.finalize().catch((error) => finish(error)) }).catch(async (error) => { await rm(outputPath, { force: true }) throw error }) }