"""브라우저용 TS 를 **서버에서 그대로 실행**하기 위한 공통 배관(2026-09-06 분리). CLAUDE.md 5장 「계산 자리」 — 같은 계산을 파이썬으로 다시 짜지 않고, 화면이 쓰는 TS 를 Node 진입점으로 감싸 서버가 부른다. 코리도(`B05_Profile_Corridor_Prebuild`)가 첫 사례고 구조물 면적(`B06_Section_Structure_Areas_Prebuild`)이 뒤따르면서, 번들 빌드·실행 배관이 두 벌이 되어 여기로 모았다. **계산은 이 파일에 없다** — 실행 껍데기만 있다. """ from __future__ import annotations import json import logging import os import subprocess import tempfile from pathlib import Path from typing import Any logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] # 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더. SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 def node_env() -> dict[str, str]: """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" env = os.environ.copy() node_modules = ROOT / "config" / "node_modules" env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" env["NODE_PATH"] = str(node_modules) return env def bundle_stale(bundle: Path) -> bool: """번들이 없거나 TS 원본보다 오래됐으면 참. 번들이 낡으면 서버와 화면이 **다른 값**을 만든다 — 이 판정이 그것을 막는 유일한 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. """ if not bundle.is_file(): return True built_at = bundle.stat().st_mtime for directory in SOURCE_DIRS: for path in (ROOT / directory).rglob("*.ts"): if path.stat().st_mtime > built_at: return True return False def build_bundle(npm_script: str) -> bool: result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 f"npm run {npm_script}", shell=True, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=BUILD_TIMEOUT_S, ) if result.returncode != 0: logger.error("Node 번들 빌드 실패(%s):\n%s", npm_script, result.stderr) return False return True def run_node(bundle: Path, input_path: Path, output_path: Path) -> int: result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 ["node", str(bundle), str(input_path), str(output_path)], cwd=str(ROOT), env=node_env(), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=RUN_TIMEOUT_S, ) if result.returncode != 0: logger.warning( "Node 실행 실패(%s, 끝 코드 %s): %s", bundle.name, result.returncode, result.stderr ) return result.returncode def run_bundle_json(bundle: Path, npm_script: str, payload: dict[str, Any]) -> Any | None: """입력을 JSON 으로 넘겨 실행하고 결과 JSON 을 돌려준다. 실패는 None. 결과가 작을 때만 쓸 것 — 코리도처럼 큰 산출물은 파일로 받아 그대로 옮겨야 한다. """ if bundle_stale(bundle) and not build_bundle(npm_script): return None with tempfile.TemporaryDirectory(prefix="node_bundle_") as workdir: source = Path(workdir) / "input.json" result = Path(workdir) / "output.json" source.write_text(json.dumps(payload, default=float), encoding="utf-8") if run_node(bundle, source, result) != 0: return None return json.loads(result.read_text(encoding="utf-8"))