Files
Pier_v1/랜딩페이지/drawing-launcher.ts
T
2026-11-03 12:04:58 +09:00

173 lines
11 KiB
TypeScript

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<string, Job>();
let latest: Job | undefined;
let choosing: Promise<string> | undefined;
const children = new Set<ChildProcess>();
const runWorker = (args: string[], event: (value: Record<string, unknown>) => void): { child: ChildProcess; done: Promise<void> } => {
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<string, unknown>;
try { value = JSON.parse(line) as Record<string, unknown>; }
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<void>((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<void> => {
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<string, unknown>;
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<string, unknown>;
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); },
};
}