import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; import { existsSync, statSync, realpathSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Plugin } from 'vite'; export const DRAWING_API_PATH = '/__pierzainer/drawing-extractor'; type Book = { name: string; xlsx_path: string; review_count: number }; type Result = { status: string; output_dir: string; review_count: number; failed_file_count: number; workbooks: Book[] }; type Job = { id: string; folder: string; output: string; status: 'running' | 'completed' | 'needs_review' | 'error'; logs: string[]; error?: string; result?: Result; child?: ChildProcess }; type Runtime = { platform?: NodeJS.Platform; exists?: (file: string) => boolean; isDirectory?: (folder: string) => boolean; start?: (file: string, args: string[], options: SpawnOptions) => ChildProcess; }; /** Popup adapter: reuse the packaged engine headlessly; keep outputs inside this project. */ export function drawingLauncherPlugin(root: string, runtime: Runtime = {}): Plugin { const app = path.join(root, '도면추출', 'app'); const pythonHome = path.join(app, 'runtime', 'python'); const executable = path.join(pythonHome, 'python.exe'); const script = path.join(root, '랜딩페이지', 'drawing-worker.py'); const outputRoot = path.join(root, '도면추출', 'output'); const exists = runtime.exists ?? existsSync; const isDirectory = runtime.isDirectory ?? ((folder: string) => { try { return statSync(folder).isDirectory(); } catch { return false; } }); const start = runtime.start ?? spawn; const jobs = new Map(); let latest: Job | undefined; let choosing: Promise | undefined; const children = new Set(); const runWorker = (args: string[], event: (value: Record) => void): { child: ChildProcess; done: Promise } => { const child = start(executable, ['-S', script, ...args], { cwd: root, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONHOME: pythonHome, PYTHONPATH: [path.join(app, 'Code', 'bridge_extractor_package', 'src'), path.join(pythonHome, 'Lib', 'site-packages')].join(path.delimiter), PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' }, }); children.add(child); let pending = '', details = '', workerError = ''; const consume = (line: string): void => { if (!line.trim()) return; let value: Record; try { value = JSON.parse(line) as Record; } catch { event({ type: 'log', message: line }); return; } if (value.type === 'error') workerError = String(value.message ?? '도면 추출 실패'); event(value); }; child.stdout?.setEncoding('utf8'); child.stdout?.on('data', (chunk: string) => { pending += chunk; let index: number; while ((index = pending.indexOf('\n')) >= 0) { consume(pending.slice(0, index)); pending = pending.slice(index + 1); } if (pending.length > 4_000_000) { workerError = '추출 응답이 너무 큽니다.'; child.kill(); } }); child.stderr?.setEncoding('utf8'); child.stderr?.on('data', (chunk: string) => { if (details.length < 4000) details += chunk.slice(0, 4000-details.length); }); const done = new Promise((resolve, reject) => { child.once('error', error => { children.delete(child); reject(error); }); child.once('close', code => { children.delete(child); consume(pending); if (code === 0 && !workerError) resolve(); else reject(new Error(workerError || details || `도면 추출 프로세스가 종료됐습니다 (${code}).`)); }); }); return { child, done }; }; const snapshot = (job?: Job) => job ? { id: job.id, folder: job.folder, status: job.status, logs: job.logs, error: job.error, result: job.result ? { status: job.result.status, outputDir: job.result.output_dir, reviewCount: job.result.review_count, failedFileCount: job.result.failed_file_count, workbooks: job.result.workbooks.map((b, index) => ({ index, name: b.name, fileName: path.basename(b.xlsx_path), reviewCount: b.review_count })) } : undefined, } : null; const handle = async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise => { const route = new URL(req.url ?? '/', 'http://localhost').pathname; if (!route.startsWith(DRAWING_API_PATH + '/')) { next(); return; } const reply = (status: number, body: object): void => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', Allow: 'POST' }); res.end(JSON.stringify(body)); }; if (req.method !== 'POST') { reply(405, { error: 'POST 요청만 지원합니다.' }); return; } const host = req.headers.host ?? '', address = req.socket.remoteAddress ?? '', origin = req.headers.origin; const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host) && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address); if (!local || (origin && origin !== `http://${host}` && origin !== `https://${host}`) || req.headers['x-pierzainer-action'] !== 'drawing-extractor') { reply(403, { error: '이 PC의 Pierzainer 화면에서 실행해 주세요.' }); return; } if ((runtime.platform ?? process.platform) !== 'win32') { reply(501, { error: '도면추출은 Windows PC에서 실행할 수 있습니다.' }); return; } if (!exists(executable) || !exists(script)) { reply(503, { error: '도면추출 실행 파일을 찾을 수 없습니다.' }); return; } let body: Record; try { let text = ''; req.setEncoding('utf8'); for await (const chunk of req) { text += String(chunk); if (text.length > 8192) { reply(413, { error: '요청이 너무 큽니다.' }); return; } } body = JSON.parse(text || '{}') as Record; if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error(); } catch { reply(400, { error: '요청 형식이 맞지 않습니다.' }); return; } const action = route.slice(DRAWING_API_PATH.length + 1); if (action === 'status') { const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : latest; reply(200, { job: snapshot(job) }); return; } if (action === 'choose-folder') { if (!choosing) choosing = (async () => { let folder = ''; const worker = runWorker(['choose'], value => { if (value.type === 'folder') folder = String(value.folder ?? ''); }); await worker.done; return folder; })(); try { reply(200, { folder: await choosing }); } finally { choosing = undefined; } return; } if (action === 'start') { if (latest?.status === 'running') { reply(409, { error: '이미 추출 중입니다.', job: snapshot(latest) }); return; } if (typeof body.folder !== 'string' || !body.folder.trim() || body.folder.includes('\0') || !isDirectory(body.folder.trim())) { reply(400, { error: 'DWG 도면이 들어 있는 폴더를 선택하세요.' }); return; } const id = randomUUID(); const job: Job = { id, folder: path.resolve(body.folder.trim()), output: path.join(outputRoot, id), status: 'running', logs: ['도면을 읽고 있습니다…'] }; jobs.set(id, job); latest = job; try { await mkdir(job.output, { recursive: true }); } catch (error) { job.status = 'error'; job.error = (error as Error).message; reply(500, { error: job.error }); return; } for (const [key, old] of jobs) { if (jobs.size <= 10) break; if (old !== job && old.status !== 'running') jobs.delete(key); } try { const worker = runWorker(['extract', job.folder, '--output', job.output], value => { if (value.type === 'log') { job.logs.push(String(value.message ?? '').slice(0, 1500)); if (job.logs.length > 500) job.logs.shift(); } if (value.type === 'result') job.result = value.result as Result; }); job.child = worker.child; void worker.done.then(() => { if (!job.result || !Array.isArray(job.result.workbooks) || !job.result.workbooks.length) throw new Error('생성된 Excel이 없습니다. 도면 판독 로그를 확인하세요.'); job.status = job.result.status === 'needs_review' ? 'needs_review' : 'completed'; }).catch(error => { job.status = 'error'; job.error = String(error.message); job.logs.push(job.error!); }); } catch (error) { job.status = 'error'; job.error = (error as Error).message; } reply(202, { job: snapshot(job) }); return; } if (action === 'workbook') { const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : undefined; const index = body.index; if (!job?.result || !['completed', 'needs_review'].includes(job.status) || !Number.isInteger(index) || (index as number) < 0) { reply(400, { error: '추출이 완료된 Excel을 선택하세요.' }); return; } const book = job.result.workbooks[index as number]; if (!book || !exists(book.xlsx_path)) { reply(404, { error: '결과 파일을 찾을 수 없습니다.' }); return; } const file = realpathSync(book.xlsx_path), output = realpathSync(job.output); if (!file.toLowerCase().startsWith((output + path.sep).toLowerCase()) || path.extname(file).toLowerCase() !== '.xlsx') { reply(403, { error: '이 작업의 결과 Excel만 불러올 수 있습니다.' }); return; } const bytes = await readFile(file); res.writeHead(200, { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control': 'no-store' }); res.end(bytes); return; } reply(404, { error: '지원하지 않는 요청입니다.' }); }; const middleware = (req: IncomingMessage, res: ServerResponse, next: () => void): void => { void handle(req, res, next).catch(error => { console.error('[drawing-extractor]', error); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: (error as Error).message || '도면추출 요청에 실패했습니다.' })); } }); }; const cleanup = (): void => { for (const child of children) child.kill(); }; return { name: 'pierzainer-drawing-popup', configureServer(server) { server.middlewares.use(middleware); server.httpServer?.once('close', cleanup); }, configurePreviewServer(server) { server.middlewares.use(middleware); server.httpServer.once('close', cleanup); }, }; }