From 56881fe99f26b13d4eafc0de086d375bccab2bc3 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 14:45:14 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(node-runtime):=20Node.js=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EB=A6=AC=EB=A5=BC=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=97=90=20=EC=9E=90=EB=8F=99=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Node.js 바이너리를 리소스에 포함하지 않고 앱 시작 시 캐시 경로에 자동 다운로드 - node_downloader 모듈 추가하여 Node.js 바이너리 다운로드 및 관리 - postinstall 스크립트 및 download-node-binaries.js 제거 - tauri.conf.json에서 Node.js 바이너리 리소스 설정 제거 - 앱 번들 크기 감소 및 버전 업데이트 용이 --- Cargo.lock | 193 ++++++++++++++++++ apps/executeJS/src-tauri/src/lib.rs | 17 ++ apps/executeJS/src-tauri/tauri.conf.json | 4 - crates/node-runtime/Cargo.toml | 8 + crates/node-runtime/src/lib.rs | 98 ++++----- crates/node-runtime/src/node_downloader.rs | 226 +++++++++++++++++++++ package.json | 4 +- scripts/download-node-binaries.js | 215 -------------------- 8 files changed, 486 insertions(+), 279 deletions(-) create mode 100644 crates/node-runtime/src/node_downloader.rs delete mode 100755 scripts/download-node-binaries.js diff --git a/Cargo.lock b/Cargo.lock index 90aa45e..2bf598e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -334,6 +345,12 @@ dependencies = [ "simd-abstraction", ] +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "bincode" version = "1.3.3" @@ -505,6 +522,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -579,6 +616,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -644,6 +683,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -722,6 +771,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "convert_case" version = "0.4.0" @@ -1114,6 +1169,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2076,6 +2132,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -2487,6 +2552,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2582,6 +2656,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2761,6 +2845,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3006,8 +3101,14 @@ name = "node-runtime" version = "0.1.0" dependencies = [ "anyhow", + "dirs 5.0.1", + "flate2", + "reqwest", + "tar", "tokio", "tracing", + "xz2", + "zip", ] [[package]] @@ -3513,6 +3614,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3525,6 +3637,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4778,6 +4902,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -7327,6 +7462,15 @@ dependencies = [ "rustix 1.1.2", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.0" @@ -7492,6 +7636,55 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/apps/executeJS/src-tauri/src/lib.rs b/apps/executeJS/src-tauri/src/lib.rs index 11a3d23..1425465 100644 --- a/apps/executeJS/src-tauri/src/lib.rs +++ b/apps/executeJS/src-tauri/src/lib.rs @@ -150,6 +150,23 @@ pub fn run() { // 앱 시작 시 초기화 작업 tauri::async_runtime::spawn(async { tracing::info!("ExecuteJS 애플리케이션이 시작되었습니다."); + + // Node.js 바이너리 확인 및 다운로드 + eprintln!("[ExecuteJS] Node.js 바이너리 확인 시작..."); + match node_runtime::NodeExecutor::ensure_node_binary().await { + Ok(path) => { + eprintln!( + "[ExecuteJS] ✅ Node.js 바이너리 준비 완료: {}", + path.display() + ); + tracing::info!("Node.js 바이너리 준비 완료: {}", path.display()); + } + Err(e) => { + eprintln!("[ExecuteJS] ❌ Node.js 바이너리 초기화 실패: {}", e); + tracing::error!("Node.js 바이너리 초기화 실패: {}", e); + // 사용자에게 알림은 나중에 UI로 표시할 수 있음 + } + } }); Ok(()) diff --git a/apps/executeJS/src-tauri/tauri.conf.json b/apps/executeJS/src-tauri/tauri.conf.json index 9f34e5a..a0fc683 100644 --- a/apps/executeJS/src-tauri/tauri.conf.json +++ b/apps/executeJS/src-tauri/tauri.conf.json @@ -37,10 +37,6 @@ "app" ], "icon": [], - "resources": [ - "resources/node-runtime/node-v24.12.0-darwin-arm64/node", - "resources/node-runtime/node-v24.12.0-win-arm64/node.exe" - ], "macOS": { "entitlements": null, "exceptionDomain": "", diff --git a/crates/node-runtime/Cargo.toml b/crates/node-runtime/Cargo.toml index bd73f81..92d2af1 100644 --- a/crates/node-runtime/Cargo.toml +++ b/crates/node-runtime/Cargo.toml @@ -13,6 +13,14 @@ anyhow.workspace = true tokio.workspace = true tracing.workspace = true +# Node.js 다운로드용 +reqwest = { version = "0.12", features = ["rustls-tls", "stream"] } +tar = "0.4" +flate2 = "1.0" +xz2 = "0.1" +zip = "0.6" +dirs = "5.0" + [dev-dependencies] tokio.workspace = true diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index 465b62a..7e7115c 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -1,4 +1,7 @@ +mod node_downloader; + use anyhow::{Context, Result}; +use node_downloader::NodeDownloader; use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::io::{AsyncReadExt, BufReader}; @@ -52,25 +55,19 @@ impl NodeExecutor { /// OS별 Node.js 바이너리 경로 찾기 fn find_node_binary() -> Result { - let (os_name, binary_name) = if cfg!(target_os = "windows") { - ("win-arm64", "node.exe") - } else if cfg!(target_os = "macos") { - if cfg!(target_arch = "aarch64") { - ("darwin-arm64", "node") - } else { - ("darwin-x64", "node") - } - } else if cfg!(target_os = "linux") { - if cfg!(target_arch = "aarch64") { - ("linux-arm64", "node") - } else { - ("linux-x64", "node") - } - } else { - anyhow::bail!("지원하지 않는 운영체제입니다: {}", std::env::consts::OS); - }; + let (os_name, arch, _extension, binary_name) = NodeDownloader::get_platform_info()?; + + // 1. 캐시 경로에서 찾기 (우선) + let cache_dir = NodeDownloader::cache_dir()?; + let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let node_path = node_dir.join(&binary_name); + + if node_path.exists() { + tracing::debug!("캐시에서 Node.js 바이너리 발견: {}", node_path.display()); + return Self::set_permissions_if_needed(node_path); + } - // 1. 개발 모드: src-tauri/resources/ 폴더에서 찾기 + // 2. 개발 모드: src-tauri/resources/ 폴더에서 찾기 (폴백) // CARGO_MANIFEST_DIR에서 src-tauri로 이동 (crates/node-runtime -> 프로젝트 루트 -> apps/executeJS/src-tauri) let manifest_dir = env!("CARGO_MANIFEST_DIR"); let crate_root = Path::new(manifest_dir); @@ -84,8 +81,8 @@ impl NodeExecutor { let resources_node_dir = tauri_dir .join("resources") .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)); - let resources_node_path = resources_node_dir.join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let resources_node_path = resources_node_dir.join(&binary_name); if resources_node_path.exists() { tracing::debug!( @@ -96,11 +93,9 @@ impl NodeExecutor { } } - // 2. 프로덕션 모드: 실행 파일 위치 기준으로 리소스 찾기 - // Tauri 앱의 경우 실행 파일과 같은 디렉토리나 리소스 디렉토리에서 찾기 + // 3. 프로덕션 모드: 실행 파일 위치 기준으로 리소스 찾기 (폴백, 거의 사용되지 않음) + // 캐시 경로가 우선이므로 이 경로는 거의 사용되지 않음 if let Ok(exe_path) = std::env::current_exe() { - eprintln!("[NodeExecutor] 실행 파일 경로: {}", exe_path.display()); - // macOS .app 번들 구조: .app/Contents/MacOS/executeJS -> .app/Contents/Resources/ #[cfg(target_os = "macos")] { @@ -130,8 +125,8 @@ impl NodeExecutor { // 1. node-runtime/node-v24.12.0-*/node (우선 확인 - tauri.conf.json 설정에 따라) let resource_path = resources_dir .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)) + .join(&binary_name); eprintln!( "[NodeExecutor] 경로 1 확인 (우선): {}", resource_path.display() @@ -145,8 +140,8 @@ impl NodeExecutor { let resource_path2 = resources_dir .join("resources") .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)) + .join(&binary_name); eprintln!("[NodeExecutor] 경로 2 확인: {}", resource_path2.display()); if resource_path2.exists() { eprintln!("[NodeExecutor] ✅ 경로 2에서 발견!"); @@ -160,8 +155,8 @@ impl NodeExecutor { .join("_up_") .join("resources") .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)) + .join(&binary_name); eprintln!( "[NodeExecutor] 경로 3 확인 (이전 호환): {}", tauri_resource_path.display() @@ -191,7 +186,7 @@ impl NodeExecutor { } // 4. 직접 Resources에 있는 경우 - let direct_resource_path = resources_dir.join(binary_name); + let direct_resource_path = resources_dir.join(&binary_name); eprintln!( "[NodeExecutor] 경로 4 확인: {}", direct_resource_path.display() @@ -221,8 +216,8 @@ impl NodeExecutor { let resource_path = path .join("resources") .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)) + .join(&binary_name); if resource_path.exists() { tracing::info!( "프로덕션 모드 (depth {}): Node.js 바이너리 경로: {}", @@ -235,8 +230,8 @@ impl NodeExecutor { // 리소스가 직접 있는 경우 (폴더 구조 없이) let direct_resource_path = path .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name); + .join(format!("node-v24.12.0-{}-{}", os_name, arch)) + .join(&binary_name); if direct_resource_path.exists() { tracing::info!( "프로덕션 모드 (직접, depth {}): Node.js 바이너리 경로: {}", @@ -247,7 +242,7 @@ impl NodeExecutor { } // Windows/Linux: 실행 파일과 같은 디렉토리 - let same_dir_path = path.join(binary_name); + let same_dir_path = path.join(&binary_name); if same_dir_path.exists() && path != exe_path.parent().unwrap() { // 실행 파일과 같은 디렉토리가 아닌 경우만 (이미 확인했으므로) // 이건 실제로는 필요 없을 수 있음 @@ -260,37 +255,26 @@ impl NodeExecutor { } } - // 에러 메시지용 경로 생성 - let error_path = if let Some(ref tauri_dir) = src_tauri_dir { - tauri_dir - .join("resources") - .join("node-runtime") - .join(format!("node-v24.12.0-{}", os_name)) - .join(binary_name) - } else { - PathBuf::from("apps/executeJS/src-tauri/resources/node-runtime/...") - }; - - // 디버깅을 위한 상세 정보 - let exe_info = std::env::current_exe() - .map(|p| format!("{}", p.display())) - .unwrap_or_else(|_| "알 수 없음".to_string()); - + // 에러 메시지 + let cache_path = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); anyhow::bail!( "Node.js 바이너리를 찾을 수 없습니다.\n\ - - 개발 모드 경로: {}\n\ - - 실행 파일 경로: {}\n\ + - 캐시 경로: {}\n\ - OS: {}, Arch: {}\n\ - 바이너리 이름: {}\n\ - src-tauri/resources/node-runtime/ 폴더에 Node.js 바이너리가 있는지 확인하세요.", - error_path.display(), - exe_info, + 앱을 재시작하면 자동으로 다운로드됩니다.", + cache_path.display(), std::env::consts::OS, std::env::consts::ARCH, binary_name ); } + /// Node.js 바이너리 확인 및 다운로드 (공개 메서드) + pub async fn ensure_node_binary() -> Result { + NodeDownloader::ensure_node_binary().await + } + /// 실행 권한 설정 (필요한 경우) fn set_permissions_if_needed(node_path: PathBuf) -> Result { // 실행 권한 확인 (Unix 계열) - 이미 실행 가능한 경우 스킵하여 파일 변경 방지 diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs new file mode 100644 index 0000000..2b4df92 --- /dev/null +++ b/crates/node-runtime/src/node_downloader.rs @@ -0,0 +1,226 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tar::Archive; +use xz2::read::XzDecoder; +use zip::ZipArchive; + +const NODE_VERSION: &str = "v24.12.0"; +const BASE_URL: &str = "https://nodejs.org/dist/v24.12.0/"; + +pub struct NodeDownloader; + +impl NodeDownloader { + /// 캐시 디렉토리 경로 반환 + pub fn cache_dir() -> Result { + let cache_dir = dirs::cache_dir() + .context("캐시 디렉토리를 찾을 수 없습니다")? + .join("executejs") + .join("node-runtime"); + + fs::create_dir_all(&cache_dir).context("캐시 디렉토리를 생성할 수 없습니다")?; + + Ok(cache_dir) + } + + /// OS 및 아키텍처 정보 반환 + pub fn get_platform_info() -> Result<(String, String, String, String)> { + let (os_name, arch, extension, binary_name) = if cfg!(target_os = "windows") { + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + ("win", arch, "zip", "node.exe") + } else if cfg!(target_os = "macos") { + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + ("darwin", arch, "tar.xz", "node") + } else if cfg!(target_os = "linux") { + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + ("linux", arch, "tar.xz", "node") + } else { + anyhow::bail!("지원하지 않는 운영체제입니다: {}", std::env::consts::OS); + }; + + Ok(( + os_name.to_string(), + arch.to_string(), + extension.to_string(), + binary_name.to_string(), + )) + } + + /// Node.js 바이너리 경로 반환 (다운로드 필요 시 다운로드) + pub async fn ensure_node_binary() -> Result { + let (os_name, arch, _extension, binary_name) = Self::get_platform_info()?; + let cache_dir = Self::cache_dir()?; + // find_node_binary와 동일한 경로 형식 사용 + let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let node_path = node_dir.join(&binary_name); + + // 이미 존재하면 반환 + if node_path.exists() { + tracing::info!("Node.js 바이너리 발견: {}", node_path.display()); + Self::set_permissions_if_needed(&node_path)?; + return Ok(node_path); + } + + // 다운로드 필요 + tracing::info!( + "Node.js 바이너리 다운로드 시작... (경로: {})", + node_path.display() + ); + if let Err(e) = Self::download_node_binary().await { + tracing::error!("Node.js 바이너리 다운로드 실패: {}", e); + return Err(e); + } + + // 다운로드 후 다시 확인 + if node_path.exists() { + Self::set_permissions_if_needed(&node_path)?; + Ok(node_path) + } else { + anyhow::bail!( + "Node.js 바이너리 다운로드 후에도 파일을 찾을 수 없습니다: {}", + node_path.display() + ); + } + } + + /// Node.js 바이너리 다운로드 + async fn download_node_binary() -> Result<()> { + let (os_name, arch, extension, binary_name) = Self::get_platform_info()?; + let cache_dir = Self::cache_dir()?; + // find_node_binary와 동일한 경로 형식 사용 + let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + + let file_name = format!("node-{}-{}-{}.{}", NODE_VERSION, os_name, arch, extension); + let download_url = format!("{}{}", BASE_URL, file_name); + + tracing::info!("Node.js 다운로드 시작: {}", download_url); + tracing::info!("캐시 디렉토리: {}", cache_dir.display()); + tracing::info!("타겟 디렉토리: {}", node_dir.display()); + + // 다운로드 + let response = reqwest::get(&download_url) + .await + .context("Node.js 다운로드 실패")?; + + let bytes = response + .bytes() + .await + .context("다운로드 데이터 읽기 실패")?; + + // 임시 파일에 저장 + let temp_file = cache_dir.join(&file_name); + let mut file = fs::File::create(&temp_file).context("임시 파일 생성 실패")?; + file.write_all(&bytes).context("파일 쓰기 실패")?; + drop(file); // 파일 핸들 닫기 + + // 압축 해제 + tracing::info!("압축 해제 중..."); + fs::create_dir_all(&cache_dir).context("캐시 디렉토리 생성 실패")?; + + // 압축 해제 후 디렉토리 이름 (Node.js 배포본의 실제 디렉토리 이름) + let extracted_dir_name = format!("node-{}-{}-{}", NODE_VERSION, os_name, arch); + let extracted_dir = cache_dir.join(&extracted_dir_name); + + if extension == "tar.xz" { + // tar.xz 압축 해제 + let tar_xz = fs::File::open(&temp_file)?; + let tar = XzDecoder::new(tar_xz); + let mut archive = Archive::new(tar); + archive + .unpack(&cache_dir) + .context("tar.xz 압축 해제 실패")?; + } else if extension == "zip" { + // Windows: zip 압축 해제 + let zip_file = fs::File::open(&temp_file)?; + let mut archive = ZipArchive::new(zip_file)?; + archive.extract(&cache_dir).context("zip 압축 해제 실패")?; + } else { + anyhow::bail!("지원하지 않는 압축 형식: {}", extension); + } + + // 바이너리 찾기 및 이동 + let source_binary = if os_name == "win" { + extracted_dir.join(&binary_name) + } else { + extracted_dir.join("bin").join(&binary_name) + }; + + if !source_binary.exists() { + anyhow::bail!( + "압축 해제 후 바이너리를 찾을 수 없습니다: {}", + source_binary.display() + ); + } + + // 타겟 디렉토리 생성 + fs::create_dir_all(&node_dir).context("Node.js 디렉토리 생성 실패")?; + + // 바이너리 복사 + let target_binary = node_dir.join(&binary_name); + tracing::info!("소스 바이너리: {}", source_binary.display()); + tracing::info!("타겟 바이너리: {}", target_binary.display()); + + if source_binary != target_binary { + tracing::info!("바이너리 복사 중..."); + fs::copy(&source_binary, &target_binary).context("바이너리 복사 실패")?; + tracing::info!("바이너리 복사 완료"); + } else { + tracing::info!("바이너리가 이미 올바른 위치에 있습니다"); + } + + // 복사 후 확인 + if !target_binary.exists() { + anyhow::bail!( + "바이너리 복사 후에도 파일을 찾을 수 없습니다: {}", + target_binary.display() + ); + } + + // 임시 파일 정리 + tracing::info!("임시 파일 정리 중..."); + let _ = fs::remove_file(&temp_file); + + // extracted_dir와 node_dir가 같은 경우 삭제하지 않음 (바이너리가 이미 올바른 위치에 있음) + if extracted_dir != node_dir && extracted_dir.exists() { + tracing::info!("압축 해제 디렉토리 정리 중: {}", extracted_dir.display()); + let _ = fs::remove_dir_all(&extracted_dir); + } else { + tracing::info!("압축 해제 디렉토리가 타겟 디렉토리와 동일하므로 정리하지 않음"); + } + + tracing::info!( + "Node.js 바이너리 다운로드 완료: {}", + target_binary.display() + ); + Ok(()) + } + + /// 실행 권한 설정 + fn set_permissions_if_needed(node_path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(node_path)?; + let perms = metadata.permissions(); + if perms.mode() & 0o111 == 0 { + let mut new_perms = perms.clone(); + new_perms.set_mode(0o755); + fs::set_permissions(node_path, new_perms)?; + } + } + Ok(()) + } +} diff --git a/package.json b/package.json index 853e857..b293e81 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,7 @@ "docs:build": "pnpm --filter @executeJS/docs build", "docs:preview": "pnpm --filter @executeJS/docs preview", "clean": "pnpm -r clean && cargo clean", - "type-check": "pnpm -r type-check", - "download-node": "node scripts/download-node-binaries.js", - "postinstall": "pnpm download-node" + "type-check": "pnpm -r type-check" }, "devDependencies": { "@types/node": "latest", diff --git a/scripts/download-node-binaries.js b/scripts/download-node-binaries.js deleted file mode 100755 index c620b8c..0000000 --- a/scripts/download-node-binaries.js +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env node - -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const { createWriteStream } = require('fs'); - -const NODE_VERSION = 'v24.12.0'; -const BASE_URL = `https://nodejs.org/dist/${NODE_VERSION}/`; - -// OS 및 아키텍처 매핑 -const getPlatformInfo = () => { - const platform = process.platform; - const arch = process.arch; - - if (platform === 'darwin') { - return { - os: 'darwin', - arch: arch === 'arm64' ? 'arm64' : 'x64', - extension: 'tar.xz', // 더 작은 파일 크기 - binaryName: 'node', - }; - } else if (platform === 'win32') { - return { - os: 'win', - arch: arch === 'arm64' ? 'arm64' : 'x64', - extension: 'zip', - binaryName: 'node.exe', - }; - } else { - // CI 환경이거나 지원하지 않는 플랫폼인 경우 조용히 종료 - // GitHub Actions는 Linux에서 실행되지만 바이너리는 필요 없음 - return null; - } -}; - -// 파일 다운로드 -const downloadFile = async (url, destPath) => { - return new Promise((resolve, reject) => { - console.log(`다운로드 중: ${url}`); - const file = createWriteStream(destPath); - - // 파일 스트림 정리 헬퍼 함수 - const cleanup = (callback) => { - file.close(() => { - fs.unlink(destPath, () => { - callback(); - }); - }); - }; - - https - .get(url, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - // 리다이렉트 처리 - 파일 스트림 정리 후 재귀 호출 - cleanup(() => { - downloadFile(response.headers.location, destPath).then(resolve).catch(reject); - }); - return; - } - - if (response.statusCode !== 200) { - // 비-200 상태 코드 - 파일 스트림 정리 후 reject - cleanup(() => { - reject(new Error(`다운로드 실패: ${response.statusCode}`)); - }); - return; - } - - const totalSize = parseInt(response.headers['content-length'], 10); - let downloadedSize = 0; - - response.on('data', (chunk) => { - downloadedSize += chunk.length; - const percent = totalSize ? ((downloadedSize / totalSize) * 100).toFixed(1) : '0.0'; - const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2); - const totalMB = totalSize ? (totalSize / 1024 / 1024).toFixed(2) : '?'; - process.stdout.write(`\r진행률: ${percent}% (${downloadedMB} MB / ${totalMB} MB)`); - }); - - response.pipe(file); - - file.on('finish', () => { - file.close(); - console.log('\n다운로드 완료!'); - resolve(); - }); - - file.on('error', (err) => { - file.close(() => { - fs.unlink(destPath, () => { - reject(err); - }); - }); - }); - }) - .on('error', (err) => { - // HTTP 요청 에러 - 파일 스트림 정리 후 reject - cleanup(() => { - reject(err); - }); - }); - }); -}; - -// 압축 해제 -const extractArchive = async (archivePath, extractDir) => { - const ext = path.extname(archivePath); - const platform = process.platform; - - console.log(`압축 해제 중: ${archivePath}`); - - if (ext === '.zip') { - // Windows: unzip 사용 - try { - execSync(`unzip -q "${archivePath}" -d "${extractDir}"`, { stdio: 'inherit' }); - } catch (error) { - // unzip이 없으면 PowerShell 사용 - execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { - stdio: 'inherit', - }); - } - } else if (ext === '.xz' || archivePath.endsWith('.tar.xz')) { - // tar.xz 압축 해제 - execSync(`tar -xJf "${archivePath}" -C "${extractDir}"`, { stdio: 'inherit' }); - } else if (ext === '.gz' || archivePath.endsWith('.tar.gz')) { - // tar.gz 압축 해제 - execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'inherit' }); - } - - console.log('압축 해제 완료!'); -}; - -// 바이너리 복사 -const copyBinary = async (platformInfo, extractDir) => { - const { os, arch, binaryName } = platformInfo; - const nodeDir = `node-${NODE_VERSION}-${os}-${arch}`; - // Windows는 루트에, macOS/Linux는 bin/ 디렉토리에 있습니다 - const sourcePath = - os === 'win' ? path.join(extractDir, nodeDir, binaryName) : path.join(extractDir, nodeDir, 'bin', binaryName); - // src-tauri/resources/에만 복사 (개발/빌드 모두 동일 경로 사용) - const targetDirs = [ - path.join(__dirname, '..', 'apps', 'executeJS', 'src-tauri', 'resources', 'node-runtime', nodeDir), - ]; - - // 소스 파일 확인 - if (!fs.existsSync(sourcePath)) { - throw new Error(`바이너리 파일을 찾을 수 없습니다: ${sourcePath}`); - } - - // 각 타겟 디렉토리에 복사 - for (const targetDir of targetDirs) { - fs.mkdirSync(targetDir, { recursive: true }); - const targetPath = path.join(targetDir, binaryName); - fs.copyFileSync(sourcePath, targetPath); - - // Unix 시스템에서 실행 권한 설정 - if (process.platform !== 'win32') { - fs.chmodSync(targetPath, 0o755); - } - - console.log(`복사 완료: ${targetPath}`); - } -}; - -// 메인 함수 -const main = async () => { - try { - const platformInfo = getPlatformInfo(); - - // 지원하지 않는 플랫폼이거나 CI 환경인 경우 조용히 종료 - if (!platformInfo) { - console.log(`현재 플랫폼(${process.platform})에서는 Node.js 바이너리 다운로드가 필요하지 않습니다.`); - return; - } - - const { os, arch, extension } = platformInfo; - - const fileName = `node-${NODE_VERSION}-${os}-${arch}.${extension}`; - const downloadUrl = `${BASE_URL}${fileName}`; - - console.log(`Node.js ${NODE_VERSION} 바이너리 다운로드 시작`); - console.log(`플랫폼: ${os}-${arch}`); - console.log(`파일: ${fileName}`); - console.log(`URL: ${downloadUrl}\n`); - - // 임시 디렉토리 생성 - const tempDir = path.join(__dirname, '..', '.temp-node-download'); - fs.mkdirSync(tempDir, { recursive: true }); - const archivePath = path.join(tempDir, fileName); - const extractDir = path.join(tempDir, 'extracted'); - - // 다운로드 - await downloadFile(downloadUrl, archivePath); - - // 압축 해제 - fs.mkdirSync(extractDir, { recursive: true }); - await extractArchive(archivePath, extractDir); - - // 바이너리 복사 - await copyBinary(platformInfo, extractDir); - - // 임시 파일 정리 - console.log('\n임시 파일 정리 중...'); - fs.rmSync(tempDir, { recursive: true, force: true }); - - console.log('\n✅ 모든 작업 완료!'); - } catch (error) { - console.error('\n❌ 오류 발생:', error.message); - process.exit(1); - } -}; - -main(); From 9cce61492ef11f2a88861691ce920e1a43869c16 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 14:48:44 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=EC=98=A4?= =?UTF-8?q?=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c134f91 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @ohah @Enjoywater @Bori-github @ming-Jo + From 21a3c1316839a2bee3f9fc1e5c6480a96affc5b1 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 14:54:28 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix(node-runtime):=20Copilot=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하드코딩된 버전 문자열을 NODE_VERSION 상수로 통일 - HTTP 응답 상태 코드 확인 추가 - 임시 파일 정리 시 에러 로깅 추가 - reqwest 의존성에 default-features = false 추가 - 사용하지 않는 flate2 의존성 제거 - 에러 메시지 개선 (find_node_binary vs ensure_node_binary) --- Cargo.lock | 28 +++++++++++++++++++- crates/node-runtime/Cargo.toml | 4 +-- crates/node-runtime/src/lib.rs | 9 ++++--- crates/node-runtime/src/node_downloader.rs | 30 +++++++++++++++++----- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2bf598e..79cc3b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -3101,8 +3121,8 @@ name = "node-runtime" version = "0.1.0" dependencies = [ "anyhow", + "const_format", "dirs 5.0.1", - "flate2", "reqwest", "tar", "tokio", @@ -6337,6 +6357,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/node-runtime/Cargo.toml b/crates/node-runtime/Cargo.toml index 92d2af1..2203653 100644 --- a/crates/node-runtime/Cargo.toml +++ b/crates/node-runtime/Cargo.toml @@ -14,12 +14,12 @@ tokio.workspace = true tracing.workspace = true # Node.js 다운로드용 -reqwest = { version = "0.12", features = ["rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } tar = "0.4" -flate2 = "1.0" xz2 = "0.1" zip = "0.6" dirs = "5.0" +const_format = "0.2" [dev-dependencies] tokio.workspace = true diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index 7e7115c..94c5974 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -59,7 +59,7 @@ impl NodeExecutor { // 1. 캐시 경로에서 찾기 (우선) let cache_dir = NodeDownloader::cache_dir()?; - let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let node_dir = cache_dir.join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); let node_path = node_dir.join(&binary_name); if node_path.exists() { @@ -81,7 +81,7 @@ impl NodeExecutor { let resources_node_dir = tauri_dir .join("resources") .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)); + .join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); let resources_node_path = resources_node_dir.join(&binary_name); if resources_node_path.exists() { @@ -256,13 +256,14 @@ impl NodeExecutor { } // 에러 메시지 - let cache_path = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let cache_path = cache_dir.join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); anyhow::bail!( "Node.js 바이너리를 찾을 수 없습니다.\n\ - 캐시 경로: {}\n\ - OS: {}, Arch: {}\n\ - 바이너리 이름: {}\n\ - 앱을 재시작하면 자동으로 다운로드됩니다.", + NodeExecutor::new()는 바이너리를 자동으로 다운로드하지 않습니다.\n\ + 앱 시작 시 ensure_node_binary()가 호출되어 자동으로 다운로드됩니다.", cache_path.display(), std::env::consts::OS, std::env::consts::ARCH, diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs index 2b4df92..6e096ad 100644 --- a/crates/node-runtime/src/node_downloader.rs +++ b/crates/node-runtime/src/node_downloader.rs @@ -6,8 +6,11 @@ use tar::Archive; use xz2::read::XzDecoder; use zip::ZipArchive; -const NODE_VERSION: &str = "v24.12.0"; -const BASE_URL: &str = "https://nodejs.org/dist/v24.12.0/"; +pub const NODE_VERSION: &str = "v24.12.0"; + +fn base_url() -> String { + format!("https://nodejs.org/dist/{}/", NODE_VERSION) +} pub struct NodeDownloader; @@ -64,7 +67,7 @@ impl NodeDownloader { let (os_name, arch, _extension, binary_name) = Self::get_platform_info()?; let cache_dir = Self::cache_dir()?; // find_node_binary와 동일한 경로 형식 사용 - let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let node_dir = cache_dir.join(format!("node-{}-{}-{}", NODE_VERSION, os_name, arch)); let node_path = node_dir.join(&binary_name); // 이미 존재하면 반환 @@ -101,10 +104,10 @@ impl NodeDownloader { let (os_name, arch, extension, binary_name) = Self::get_platform_info()?; let cache_dir = Self::cache_dir()?; // find_node_binary와 동일한 경로 형식 사용 - let node_dir = cache_dir.join(format!("node-v24.12.0-{}-{}", os_name, arch)); + let node_dir = cache_dir.join(format!("node-{}-{}-{}", NODE_VERSION, os_name, arch)); let file_name = format!("node-{}-{}-{}.{}", NODE_VERSION, os_name, arch, extension); - let download_url = format!("{}{}", BASE_URL, file_name); + let download_url = format!("{}{}", base_url(), file_name); tracing::info!("Node.js 다운로드 시작: {}", download_url); tracing::info!("캐시 디렉토리: {}", cache_dir.display()); @@ -115,6 +118,15 @@ impl NodeDownloader { .await .context("Node.js 다운로드 실패")?; + // HTTP 응답 상태 코드 확인 + if !response.status().is_success() { + anyhow::bail!( + "Node.js 다운로드 실패: HTTP {} - {}", + response.status(), + response.status().canonical_reason().unwrap_or("알 수 없는 오류") + ); + } + let bytes = response .bytes() .await @@ -191,12 +203,16 @@ impl NodeDownloader { // 임시 파일 정리 tracing::info!("임시 파일 정리 중..."); - let _ = fs::remove_file(&temp_file); + if let Err(e) = fs::remove_file(&temp_file) { + tracing::warn!("임시 파일 삭제 실패 ({}): {}", temp_file.display(), e); + } // extracted_dir와 node_dir가 같은 경우 삭제하지 않음 (바이너리가 이미 올바른 위치에 있음) if extracted_dir != node_dir && extracted_dir.exists() { tracing::info!("압축 해제 디렉토리 정리 중: {}", extracted_dir.display()); - let _ = fs::remove_dir_all(&extracted_dir); + if let Err(e) = fs::remove_dir_all(&extracted_dir) { + tracing::warn!("압축 해제 디렉토리 삭제 실패 ({}): {}", extracted_dir.display(), e); + } } else { tracing::info!("압축 해제 디렉토리가 타겟 디렉토리와 동일하므로 정리하지 않음"); } From 2623b37783e8e0e9494900467aeda5d1f72006fb Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 14:58:12 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(node-runtime):=20Copilot=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=B6=94=EA=B0=80=20=ED=95=AD=EB=AA=A9=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SHA256 체크섬 검증 추가 (파일 무결성 보장) - 동시 다운로드 보호 (파일 락) 구현 - NodeDownloader 모듈 테스트 커버리지 추가 - get_platform_info 테스트 - cache_dir 테스트 - NODE_VERSION 상수 테스트 - base_url 테스트 --- Cargo.lock | 39 ++--- crates/node-runtime/Cargo.toml | 3 +- crates/node-runtime/src/lib.rs | 28 +++- crates/node-runtime/src/node_downloader.rs | 174 +++++++++++++++++++++ 4 files changed, 210 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79cc3b8..4be802b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,26 +771,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1612,6 +1592,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fslock" version = "0.2.1" @@ -3121,9 +3111,10 @@ name = "node-runtime" version = "0.1.0" dependencies = [ "anyhow", - "const_format", "dirs 5.0.1", + "fs2", "reqwest", + "sha2", "tar", "tokio", "tracing", @@ -6357,12 +6348,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/node-runtime/Cargo.toml b/crates/node-runtime/Cargo.toml index 2203653..8a88f2b 100644 --- a/crates/node-runtime/Cargo.toml +++ b/crates/node-runtime/Cargo.toml @@ -19,7 +19,8 @@ tar = "0.4" xz2 = "0.1" zip = "0.6" dirs = "5.0" -const_format = "0.2" +sha2 = "0.10" +fs2 = "0.4" [dev-dependencies] tokio.workspace = true diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index 94c5974..d682497 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -59,7 +59,12 @@ impl NodeExecutor { // 1. 캐시 경로에서 찾기 (우선) let cache_dir = NodeDownloader::cache_dir()?; - let node_dir = cache_dir.join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); + let node_dir = cache_dir.join(format!( + "node-{}-{}-{}", + node_downloader::NODE_VERSION, + os_name, + arch + )); let node_path = node_dir.join(&binary_name); if node_path.exists() { @@ -78,10 +83,16 @@ impl NodeExecutor { .map(|p| p.join("apps").join("executeJS").join("src-tauri")); if let Some(ref tauri_dir) = src_tauri_dir { - let resources_node_dir = tauri_dir - .join("resources") - .join("node-runtime") - .join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); + let resources_node_dir = + tauri_dir + .join("resources") + .join("node-runtime") + .join(format!( + "node-{}-{}-{}", + node_downloader::NODE_VERSION, + os_name, + arch + )); let resources_node_path = resources_node_dir.join(&binary_name); if resources_node_path.exists() { @@ -256,7 +267,12 @@ impl NodeExecutor { } // 에러 메시지 - let cache_path = cache_dir.join(format!("node-{}-{}-{}", node_downloader::NODE_VERSION, os_name, arch)); + let cache_path = cache_dir.join(format!( + "node-{}-{}-{}", + node_downloader::NODE_VERSION, + os_name, + arch + )); anyhow::bail!( "Node.js 바이너리를 찾을 수 없습니다.\n\ - 캐시 경로: {}\n\ diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs index 6e096ad..55179d5 100644 --- a/crates/node-runtime/src/node_downloader.rs +++ b/crates/node-runtime/src/node_downloader.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -108,11 +109,24 @@ impl NodeDownloader { let file_name = format!("node-{}-{}-{}.{}", NODE_VERSION, os_name, arch, extension); let download_url = format!("{}{}", base_url(), file_name); + let lock_file = cache_dir.join(format!("{}.lock", file_name)); + + // 동시 다운로드 보호: 락 파일 생성 + let _lock_guard = Self::acquire_download_lock(&lock_file).await?; tracing::info!("Node.js 다운로드 시작: {}", download_url); tracing::info!("캐시 디렉토리: {}", cache_dir.display()); tracing::info!("타겟 디렉토리: {}", node_dir.display()); + // 락 획득 후 다시 확인 (다른 프로세스가 이미 다운로드 완료했을 수 있음) + let node_path = node_dir.join(&binary_name); + if node_path.exists() { + tracing::info!("다른 프로세스가 이미 다운로드를 완료했습니다"); + // 락 파일 정리 + let _ = fs::remove_file(&lock_file); + return Ok(()); + } + // 다운로드 let response = reqwest::get(&download_url) .await @@ -132,6 +146,11 @@ impl NodeDownloader { .await .context("다운로드 데이터 읽기 실패")?; + // SHA256 체크섬 검증 + tracing::info!("파일 무결성 검증 중..."); + Self::verify_checksum(&file_name, &bytes).await?; + tracing::info!("파일 무결성 검증 완료"); + // 임시 파일에 저장 let temp_file = cache_dir.join(&file_name); let mut file = fs::File::create(&temp_file).context("임시 파일 생성 실패")?; @@ -221,6 +240,12 @@ impl NodeDownloader { "Node.js 바이너리 다운로드 완료: {}", target_binary.display() ); + + // 락 파일 정리 + if let Err(e) = fs::remove_file(&lock_file) { + tracing::warn!("락 파일 삭제 실패 ({}): {}", lock_file.display(), e); + } + Ok(()) } @@ -239,4 +264,153 @@ impl NodeDownloader { } Ok(()) } + + /// 다운로드 락 획득 (동시 다운로드 방지) + /// 반환된 파일 핸들은 함수가 끝날 때까지 유지되어 락이 유지됩니다. + async fn acquire_download_lock(lock_file: &Path) -> Result { + use fs2::FileExt; + use std::fs::OpenOptions; + + // 락 파일 생성 및 배타적 락 획득 + let file = OpenOptions::new() + .create(true) + .write(true) + .open(lock_file) + .context("락 파일 생성 실패")?; + + // 비동기적으로 락 획득 시도 (블로킹) + let file_clone = file.try_clone()?; + tokio::task::spawn_blocking(move || { + file_clone + .lock_exclusive() + .context("다운로드 락 획득 실패 (다른 프로세스가 다운로드 중일 수 있음)") + }) + .await + .context("락 획득 작업 실패")??; + + Ok(file) + } + + /// SHA256 체크섬 검증 + async fn verify_checksum(file_name: &str, file_bytes: &[u8]) -> Result<()> { + // SHASUMS256.txt 다운로드 + let checksums_url = format!("{}SHASUMS256.txt", base_url()); + tracing::debug!("체크섬 파일 다운로드: {}", checksums_url); + + let response = reqwest::get(&checksums_url) + .await + .context("체크섬 파일 다운로드 실패")?; + + if !response.status().is_success() { + tracing::warn!( + "체크섬 파일 다운로드 실패: HTTP {} - 무결성 검증을 건너뜁니다", + response.status() + ); + return Ok(()); // 체크섬 검증 실패 시 경고만 하고 계속 진행 + } + + let checksums_text = response + .text() + .await + .context("체크섬 파일 읽기 실패")?; + + // 파일의 SHA256 계산 + let mut hasher = Sha256::new(); + hasher.update(file_bytes); + let computed_hash = hasher.finalize(); + let computed_hash_hex = format!("{:x}", computed_hash); + + // 체크섬 파일에서 해당 파일의 해시 찾기 + for line in checksums_text.lines() { + if line.ends_with(file_name) { + let expected_hash = line + .split_whitespace() + .next() + .context("체크섬 파일 형식 오류")?; + + if computed_hash_hex == expected_hash { + tracing::info!("파일 무결성 검증 성공"); + return Ok(()); + } else { + anyhow::bail!( + "파일 무결성 검증 실패: 예상 해시 {}, 실제 해시 {}", + expected_hash, + computed_hash_hex + ); + } + } + } + + tracing::warn!( + "체크섬 파일에서 {}에 대한 해시를 찾을 수 없습니다 - 무결성 검증을 건너뜁니다", + file_name + ); + Ok(()) // 체크섬 파일에 없으면 경고만 하고 계속 진행 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_platform_info() { + let result = NodeDownloader::get_platform_info(); + assert!(result.is_ok()); + + let (os_name, arch, extension, binary_name) = result.unwrap(); + assert!(!os_name.is_empty()); + assert!(!arch.is_empty()); + assert!(!extension.is_empty()); + assert!(!binary_name.is_empty()); + + // 플랫폼별 검증 + #[cfg(target_os = "macos")] + { + assert_eq!(os_name, "darwin"); + assert!(extension == "tar.xz"); + assert_eq!(binary_name, "node"); + } + + #[cfg(target_os = "windows")] + { + assert_eq!(os_name, "win"); + assert!(extension == "zip"); + assert_eq!(binary_name, "node.exe"); + } + + #[cfg(target_os = "linux")] + { + assert_eq!(os_name, "linux"); + assert!(extension == "tar.xz"); + assert_eq!(binary_name, "node"); + } + } + + #[test] + fn test_cache_dir() { + let result = NodeDownloader::cache_dir(); + assert!(result.is_ok()); + + let cache_dir = result.unwrap(); + assert!(cache_dir.to_string_lossy().contains("executejs")); + assert!(cache_dir.to_string_lossy().contains("node-runtime")); + assert!(cache_dir.exists() || cache_dir.parent().unwrap().exists()); + } + + #[test] + fn test_node_version_constant() { + // NODE_VERSION 상수가 올바르게 정의되어 있는지 확인 + assert_eq!(NODE_VERSION, "v24.12.0"); + assert!(NODE_VERSION.starts_with('v')); + } + + #[test] + fn test_base_url() { + let url = base_url(); + assert!(url.contains("nodejs.org")); + assert!(url.contains("dist")); + assert!(url.contains(NODE_VERSION)); + assert!(url.ends_with('/')); + } } From 91695adb4bc22234292909d6f954ed64aa70a306 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 14:59:20 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor(node-runtime):=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C/=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐시 경로만 사용하도록 단순화 - 개발 모드 src-tauri/resources/ 경로 찾기 제거 - 프로덕션 모드 실행 파일 기준 리소스 찾기 제거 - 모든 Node.js 바이너리는 캐시 경로에서만 로드 --- crates/node-runtime/src/lib.rs | 198 +-------------------- crates/node-runtime/src/node_downloader.rs | 16 +- 2 files changed, 12 insertions(+), 202 deletions(-) diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index d682497..9f2eb9a 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -2,7 +2,7 @@ mod node_downloader; use anyhow::{Context, Result}; use node_downloader::NodeDownloader; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Stdio; use tokio::io::{AsyncReadExt, BufReader}; use tokio::process::Command; @@ -57,7 +57,7 @@ impl NodeExecutor { fn find_node_binary() -> Result { let (os_name, arch, _extension, binary_name) = NodeDownloader::get_platform_info()?; - // 1. 캐시 경로에서 찾기 (우선) + // 캐시 경로에서만 찾기 let cache_dir = NodeDownloader::cache_dir()?; let node_dir = cache_dir.join(format!( "node-{}-{}-{}", @@ -72,200 +72,6 @@ impl NodeExecutor { return Self::set_permissions_if_needed(node_path); } - // 2. 개발 모드: src-tauri/resources/ 폴더에서 찾기 (폴백) - // CARGO_MANIFEST_DIR에서 src-tauri로 이동 (crates/node-runtime -> 프로젝트 루트 -> apps/executeJS/src-tauri) - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let crate_root = Path::new(manifest_dir); - // crates/node-runtime -> 프로젝트 루트 -> apps/executeJS/src-tauri - let src_tauri_dir = crate_root - .parent() - .and_then(|p| p.parent()) - .map(|p| p.join("apps").join("executeJS").join("src-tauri")); - - if let Some(ref tauri_dir) = src_tauri_dir { - let resources_node_dir = - tauri_dir - .join("resources") - .join("node-runtime") - .join(format!( - "node-{}-{}-{}", - node_downloader::NODE_VERSION, - os_name, - arch - )); - let resources_node_path = resources_node_dir.join(&binary_name); - - if resources_node_path.exists() { - tracing::debug!( - "개발 모드: Node.js 바이너리 경로: {}", - resources_node_path.display() - ); - return Self::set_permissions_if_needed(resources_node_path); - } - } - - // 3. 프로덕션 모드: 실행 파일 위치 기준으로 리소스 찾기 (폴백, 거의 사용되지 않음) - // 캐시 경로가 우선이므로 이 경로는 거의 사용되지 않음 - if let Ok(exe_path) = std::env::current_exe() { - // macOS .app 번들 구조: .app/Contents/MacOS/executeJS -> .app/Contents/Resources/ - #[cfg(target_os = "macos")] - { - // .app/Contents/Resources/ 경로 확인 - if let Some(macos_dir) = exe_path.parent() { - eprintln!("[NodeExecutor] MacOS 디렉토리: {}", macos_dir.display()); - - // MacOS 디렉토리에서 Contents로 이동 - if macos_dir.ends_with("MacOS") - || macos_dir.file_name().and_then(|n| n.to_str()) == Some("MacOS") - { - if let Some(contents_dir) = macos_dir.parent() { - eprintln!( - "[NodeExecutor] Contents 디렉토리: {}", - contents_dir.display() - ); - let resources_dir = contents_dir.join("Resources"); - eprintln!( - "[NodeExecutor] Resources 디렉토리 확인: {}", - resources_dir.display() - ); - - // Tauri가 리소스를 포함할 때의 경로 구조 확인 - // tauri.conf.json 설정: "resources/node-runtime/": "node-runtime/" - // 따라서 Resources/node-runtime/... 경로에 있음 - - // 1. node-runtime/node-v24.12.0-*/node (우선 확인 - tauri.conf.json 설정에 따라) - let resource_path = resources_dir - .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)) - .join(&binary_name); - eprintln!( - "[NodeExecutor] 경로 1 확인 (우선): {}", - resource_path.display() - ); - if resource_path.exists() { - eprintln!("[NodeExecutor] ✅ 경로 1에서 발견!"); - return Self::set_permissions_if_needed(resource_path); - } - - // 2. resources/node-runtime/node-v24.12.0-*/node (다른 구조) - let resource_path2 = resources_dir - .join("resources") - .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)) - .join(&binary_name); - eprintln!("[NodeExecutor] 경로 2 확인: {}", resource_path2.display()); - if resource_path2.exists() { - eprintln!("[NodeExecutor] ✅ 경로 2에서 발견!"); - return Self::set_permissions_if_needed(resource_path2); - } - - // 3. _up_/_up_/_up_/resources/node-runtime/... (이전 설정 호환성) - let tauri_resource_path = resources_dir - .join("_up_") - .join("_up_") - .join("_up_") - .join("resources") - .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)) - .join(&binary_name); - eprintln!( - "[NodeExecutor] 경로 3 확인 (이전 호환): {}", - tauri_resource_path.display() - ); - if tauri_resource_path.exists() { - eprintln!("[NodeExecutor] ✅ 경로 3에서 발견!"); - return Self::set_permissions_if_needed(tauri_resource_path); - } - - // Resources 디렉토리 전체 구조 확인 (디버깅용) - eprintln!("[NodeExecutor] Resources 디렉토리 전체 구조:"); - if let Ok(entries) = std::fs::read_dir(&resources_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - eprintln!(" [DIR] {}", path.display()); - // 하위 디렉토리도 확인 - if let Ok(sub_entries) = std::fs::read_dir(&path) { - for sub_entry in sub_entries.flatten() { - eprintln!(" - {}", sub_entry.path().display()); - } - } - } else { - eprintln!(" [FILE] {}", path.display()); - } - } - } - - // 4. 직접 Resources에 있는 경우 - let direct_resource_path = resources_dir.join(&binary_name); - eprintln!( - "[NodeExecutor] 경로 4 확인: {}", - direct_resource_path.display() - ); - if direct_resource_path.exists() { - eprintln!("[NodeExecutor] ✅ 경로 4에서 발견!"); - return Self::set_permissions_if_needed(direct_resource_path); - } - - // Resources 디렉토리 내용 확인 (디버깅용) - if let Ok(entries) = std::fs::read_dir(&resources_dir) { - eprintln!("[NodeExecutor] Resources 디렉토리 내용:"); - for entry in entries.flatten() { - eprintln!(" - {}", entry.path().display()); - } - } - } - } - } - } - - // 실행 파일의 부모 디렉토리들에서 resources 폴더 찾기 - let mut search_path = exe_path.parent(); - for depth in 0..10 { - if let Some(path) = search_path { - // 일반 resources 폴더 - let resource_path = path - .join("resources") - .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)) - .join(&binary_name); - if resource_path.exists() { - tracing::info!( - "프로덕션 모드 (depth {}): Node.js 바이너리 경로: {}", - depth, - resource_path.display() - ); - return Self::set_permissions_if_needed(resource_path); - } - - // 리소스가 직접 있는 경우 (폴더 구조 없이) - let direct_resource_path = path - .join("node-runtime") - .join(format!("node-v24.12.0-{}-{}", os_name, arch)) - .join(&binary_name); - if direct_resource_path.exists() { - tracing::info!( - "프로덕션 모드 (직접, depth {}): Node.js 바이너리 경로: {}", - depth, - direct_resource_path.display() - ); - return Self::set_permissions_if_needed(direct_resource_path); - } - - // Windows/Linux: 실행 파일과 같은 디렉토리 - let same_dir_path = path.join(&binary_name); - if same_dir_path.exists() && path != exe_path.parent().unwrap() { - // 실행 파일과 같은 디렉토리가 아닌 경우만 (이미 확인했으므로) - // 이건 실제로는 필요 없을 수 있음 - } - - search_path = path.parent(); - } else { - break; - } - } - } - // 에러 메시지 let cache_path = cache_dir.join(format!( "node-{}-{}-{}", diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs index 55179d5..d8d8272 100644 --- a/crates/node-runtime/src/node_downloader.rs +++ b/crates/node-runtime/src/node_downloader.rs @@ -137,7 +137,10 @@ impl NodeDownloader { anyhow::bail!( "Node.js 다운로드 실패: HTTP {} - {}", response.status(), - response.status().canonical_reason().unwrap_or("알 수 없는 오류") + response + .status() + .canonical_reason() + .unwrap_or("알 수 없는 오류") ); } @@ -230,7 +233,11 @@ impl NodeDownloader { if extracted_dir != node_dir && extracted_dir.exists() { tracing::info!("압축 해제 디렉토리 정리 중: {}", extracted_dir.display()); if let Err(e) = fs::remove_dir_all(&extracted_dir) { - tracing::warn!("압축 해제 디렉토리 삭제 실패 ({}): {}", extracted_dir.display(), e); + tracing::warn!( + "압축 해제 디렉토리 삭제 실패 ({}): {}", + extracted_dir.display(), + e + ); } } else { tracing::info!("압축 해제 디렉토리가 타겟 디렉토리와 동일하므로 정리하지 않음"); @@ -309,10 +316,7 @@ impl NodeDownloader { return Ok(()); // 체크섬 검증 실패 시 경고만 하고 계속 진행 } - let checksums_text = response - .text() - .await - .context("체크섬 파일 읽기 실패")?; + let checksums_text = response.text().await.context("체크섬 파일 읽기 실패")?; // 파일의 SHA256 계산 let mut hasher = Sha256::new(); From d975c3e5dec304762c79f86a7da2da17c32ac619 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 15:03:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor(node-runtime):=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - execution.rs: ExecutionOutput 구조체 분리 - executor.rs: NodeExecutor 구조체 및 실행 로직 분리 - tests/executor_test.rs: 통합 테스트 코드 분리 - lib.rs: 모듈 선언 및 공개 API만 유지 --- crates/node-runtime/src/execution.rs | 30 ++ crates/node-runtime/src/executor.rs | 176 ++++++++++++ crates/node-runtime/src/lib.rs | 315 +-------------------- crates/node-runtime/tests/executor_test.rs | 102 +++++++ 4 files changed, 314 insertions(+), 309 deletions(-) create mode 100644 crates/node-runtime/src/execution.rs create mode 100644 crates/node-runtime/src/executor.rs create mode 100644 crates/node-runtime/tests/executor_test.rs diff --git a/crates/node-runtime/src/execution.rs b/crates/node-runtime/src/execution.rs new file mode 100644 index 0000000..1df36fb --- /dev/null +++ b/crates/node-runtime/src/execution.rs @@ -0,0 +1,30 @@ +/// JavaScript 실행 결과를 저장하는 구조체 +#[derive(Debug, Clone)] +pub struct ExecutionOutput { + pub stdout: String, + pub stderr: String, +} + +impl ExecutionOutput { + pub fn new() -> Self { + Self { + stdout: String::new(), + stderr: String::new(), + } + } + + pub fn get_output(&self) -> String { + let mut output = Vec::new(); + + if !self.stdout.is_empty() { + output.push(self.stdout.clone()); + } + + if !self.stderr.is_empty() { + output.push(format!("[ERROR] {}", self.stderr)); + } + + output.join("\n") + } +} + diff --git a/crates/node-runtime/src/executor.rs b/crates/node-runtime/src/executor.rs new file mode 100644 index 0000000..ac60cfb --- /dev/null +++ b/crates/node-runtime/src/executor.rs @@ -0,0 +1,176 @@ +use crate::execution::ExecutionOutput; +use crate::node_downloader::NodeDownloader; +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncReadExt, BufReader}; +use tokio::process::Command; + +/// JavaScript 실행기 (Node.js 기반) +pub struct NodeExecutor { + node_path: PathBuf, +} + +impl NodeExecutor { + /// 새로운 NodeExecutor 인스턴스 생성 + pub fn new() -> Result { + let node_path = Self::find_node_binary().map_err(|e| { + tracing::error!("Node.js 바이너리를 찾을 수 없습니다: {}", e); + e + })?; + tracing::info!("NodeExecutor 초기화 완료: {}", node_path.display()); + Ok(Self { node_path }) + } + + /// OS별 Node.js 바이너리 경로 찾기 + fn find_node_binary() -> Result { + let (os_name, arch, _extension, binary_name) = NodeDownloader::get_platform_info()?; + + // 캐시 경로에서만 찾기 + let cache_dir = NodeDownloader::cache_dir()?; + let node_dir = cache_dir.join(format!( + "node-{}-{}-{}", + crate::node_downloader::NODE_VERSION, + os_name, + arch + )); + let node_path = node_dir.join(&binary_name); + + if node_path.exists() { + tracing::debug!("캐시에서 Node.js 바이너리 발견: {}", node_path.display()); + return Self::set_permissions_if_needed(node_path); + } + + // 에러 메시지 + let cache_path = cache_dir.join(format!( + "node-{}-{}-{}", + crate::node_downloader::NODE_VERSION, + os_name, + arch + )); + anyhow::bail!( + "Node.js 바이너리를 찾을 수 없습니다.\n\ + - 캐시 경로: {}\n\ + - OS: {}, Arch: {}\n\ + - 바이너리 이름: {}\n\ + NodeExecutor::new()는 바이너리를 자동으로 다운로드하지 않습니다.\n\ + 앱 시작 시 ensure_node_binary()가 호출되어 자동으로 다운로드됩니다.", + cache_path.display(), + std::env::consts::OS, + std::env::consts::ARCH, + binary_name + ); + } + + /// Node.js 바이너리 확인 및 다운로드 (공개 메서드) + pub async fn ensure_node_binary() -> Result { + NodeDownloader::ensure_node_binary().await + } + + /// 실행 권한 설정 (필요한 경우) + fn set_permissions_if_needed(node_path: PathBuf) -> Result { + // 실행 권한 확인 (Unix 계열) - 이미 실행 가능한 경우 스킵하여 파일 변경 방지 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&node_path).with_context(|| { + format!( + "파일 메타데이터를 읽을 수 없습니다: {}", + node_path.display() + ) + })?; + let perms = metadata.permissions(); + // 이미 실행 권한이 있는 경우 스킵 (파일 변경 방지) + if perms.mode() & 0o111 == 0 { + // 실행 권한이 없는 경우에만 설정 + let mut new_perms = perms.clone(); + new_perms.set_mode(0o755); + std::fs::set_permissions(&node_path, new_perms).with_context(|| { + format!("실행 권한을 설정할 수 없습니다: {}", node_path.display()) + })?; + } + } + + Ok(node_path) + } + + /// JavaScript 코드 실행 + pub async fn execute_script(&self, _filename: &str, code: &str) -> Result { + tracing::debug!("Node.js 코드 실행 시작, 코드 길이: {} bytes", code.len()); + + // 임시 디렉토리를 working directory로 설정하여 프로젝트 폴더 변경 방지 + let temp_dir = std::env::temp_dir(); + + // Node.js subprocess 실행 (stdin으로 코드 전달) + let mut child = Command::new(&self.node_path) + .current_dir(&temp_dir) // 임시 디렉토리에서 실행하여 프로젝트 폴더 변경 방지 + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "Node.js 프로세스를 시작할 수 없습니다: {}", + self.node_path.display() + ) + })?; + + // stdin에 코드 쓰기 + let mut stdin = child.stdin.take().expect("stdin이 설정되지 않았습니다"); + use tokio::io::AsyncWriteExt; + stdin + .write_all(code.as_bytes()) + .await + .context("stdin에 코드 쓰기 실패")?; + drop(stdin); // stdin 닫기 + + // stdout와 stderr를 비동기로 읽기 + let stdout = child.stdout.take().expect("stdout가 설정되지 않았습니다"); + let stderr = child.stderr.take().expect("stderr가 설정되지 않았습니다"); + + let mut stdout_reader = BufReader::new(stdout); + let mut stderr_reader = BufReader::new(stderr); + + let mut stdout_buf = String::new(); + let mut stderr_buf = String::new(); + + // stdout와 stderr를 동시에 읽기 + let (stdout_result, stderr_result) = tokio::join!( + stdout_reader.read_to_string(&mut stdout_buf), + stderr_reader.read_to_string(&mut stderr_buf) + ); + + stdout_result.context("stdout 읽기 실패")?; + stderr_result.context("stderr 읽기 실패")?; + + // 프로세스 종료 대기 + let status = child.wait().await.context("프로세스 종료 대기 실패")?; + + // 출력 버퍼 생성 + let mut output = ExecutionOutput::new(); + output.stdout = stdout_buf.trim().to_string(); + output.stderr = stderr_buf.trim().to_string(); + + // 프로세스가 실패한 경우 (0이 아닌 종료 코드) + if !status.success() { + let error_msg = if !output.stderr.is_empty() { + output.stderr.clone() + } else { + format!( + "프로세스가 종료 코드 {}로 종료되었습니다", + status.code().unwrap_or(-1) + ) + }; + return Err(anyhow::anyhow!("{}", error_msg)); + } + + let result_text = output.get_output(); + + if result_text.is_empty() { + Ok("코드가 실행되었습니다.".to_string()) + } else { + Ok(result_text) + } + } +} + diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index 9f2eb9a..ba180de 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -1,311 +1,8 @@ +mod execution; +mod executor; mod node_downloader; -use anyhow::{Context, Result}; -use node_downloader::NodeDownloader; -use std::path::PathBuf; -use std::process::Stdio; -use tokio::io::{AsyncReadExt, BufReader}; -use tokio::process::Command; - -/// JavaScript 실행 결과를 저장하는 구조체 -#[derive(Debug, Clone)] -pub struct ExecutionOutput { - pub stdout: String, - pub stderr: String, -} - -impl ExecutionOutput { - pub fn new() -> Self { - Self { - stdout: String::new(), - stderr: String::new(), - } - } - - pub fn get_output(&self) -> String { - let mut output = Vec::new(); - - if !self.stdout.is_empty() { - output.push(self.stdout.clone()); - } - - if !self.stderr.is_empty() { - output.push(format!("[ERROR] {}", self.stderr)); - } - - output.join("\n") - } -} - -/// JavaScript 실행기 (Node.js 기반) -pub struct NodeExecutor { - node_path: PathBuf, -} - -impl NodeExecutor { - /// 새로운 NodeExecutor 인스턴스 생성 - pub fn new() -> Result { - let node_path = Self::find_node_binary().map_err(|e| { - tracing::error!("Node.js 바이너리를 찾을 수 없습니다: {}", e); - e - })?; - tracing::info!("NodeExecutor 초기화 완료: {}", node_path.display()); - Ok(Self { node_path }) - } - - /// OS별 Node.js 바이너리 경로 찾기 - fn find_node_binary() -> Result { - let (os_name, arch, _extension, binary_name) = NodeDownloader::get_platform_info()?; - - // 캐시 경로에서만 찾기 - let cache_dir = NodeDownloader::cache_dir()?; - let node_dir = cache_dir.join(format!( - "node-{}-{}-{}", - node_downloader::NODE_VERSION, - os_name, - arch - )); - let node_path = node_dir.join(&binary_name); - - if node_path.exists() { - tracing::debug!("캐시에서 Node.js 바이너리 발견: {}", node_path.display()); - return Self::set_permissions_if_needed(node_path); - } - - // 에러 메시지 - let cache_path = cache_dir.join(format!( - "node-{}-{}-{}", - node_downloader::NODE_VERSION, - os_name, - arch - )); - anyhow::bail!( - "Node.js 바이너리를 찾을 수 없습니다.\n\ - - 캐시 경로: {}\n\ - - OS: {}, Arch: {}\n\ - - 바이너리 이름: {}\n\ - NodeExecutor::new()는 바이너리를 자동으로 다운로드하지 않습니다.\n\ - 앱 시작 시 ensure_node_binary()가 호출되어 자동으로 다운로드됩니다.", - cache_path.display(), - std::env::consts::OS, - std::env::consts::ARCH, - binary_name - ); - } - - /// Node.js 바이너리 확인 및 다운로드 (공개 메서드) - pub async fn ensure_node_binary() -> Result { - NodeDownloader::ensure_node_binary().await - } - - /// 실행 권한 설정 (필요한 경우) - fn set_permissions_if_needed(node_path: PathBuf) -> Result { - // 실행 권한 확인 (Unix 계열) - 이미 실행 가능한 경우 스킵하여 파일 변경 방지 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = std::fs::metadata(&node_path).with_context(|| { - format!( - "파일 메타데이터를 읽을 수 없습니다: {}", - node_path.display() - ) - })?; - let perms = metadata.permissions(); - // 이미 실행 권한이 있는 경우 스킵 (파일 변경 방지) - if perms.mode() & 0o111 == 0 { - // 실행 권한이 없는 경우에만 설정 - let mut new_perms = perms.clone(); - new_perms.set_mode(0o755); - std::fs::set_permissions(&node_path, new_perms).with_context(|| { - format!("실행 권한을 설정할 수 없습니다: {}", node_path.display()) - })?; - } - } - - Ok(node_path) - } - - /// JavaScript 코드 실행 - pub async fn execute_script(&self, _filename: &str, code: &str) -> Result { - tracing::debug!("Node.js 코드 실행 시작, 코드 길이: {} bytes", code.len()); - - // 임시 디렉토리를 working directory로 설정하여 프로젝트 폴더 변경 방지 - let temp_dir = std::env::temp_dir(); - - // Node.js subprocess 실행 (stdin으로 코드 전달) - let mut child = Command::new(&self.node_path) - .current_dir(&temp_dir) // 임시 디렉토리에서 실행하여 프로젝트 폴더 변경 방지 - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| { - format!( - "Node.js 프로세스를 시작할 수 없습니다: {}", - self.node_path.display() - ) - })?; - - // stdin에 코드 쓰기 - let mut stdin = child.stdin.take().expect("stdin이 설정되지 않았습니다"); - use tokio::io::AsyncWriteExt; - stdin - .write_all(code.as_bytes()) - .await - .context("stdin에 코드 쓰기 실패")?; - drop(stdin); // stdin 닫기 - - // stdout와 stderr를 비동기로 읽기 - let stdout = child.stdout.take().expect("stdout가 설정되지 않았습니다"); - let stderr = child.stderr.take().expect("stderr가 설정되지 않았습니다"); - - let mut stdout_reader = BufReader::new(stdout); - let mut stderr_reader = BufReader::new(stderr); - - let mut stdout_buf = String::new(); - let mut stderr_buf = String::new(); - - // stdout와 stderr를 동시에 읽기 - let (stdout_result, stderr_result) = tokio::join!( - stdout_reader.read_to_string(&mut stdout_buf), - stderr_reader.read_to_string(&mut stderr_buf) - ); - - stdout_result.context("stdout 읽기 실패")?; - stderr_result.context("stderr 읽기 실패")?; - - // 프로세스 종료 대기 - let status = child.wait().await.context("프로세스 종료 대기 실패")?; - - // 출력 버퍼 생성 - let mut output = ExecutionOutput::new(); - output.stdout = stdout_buf.trim().to_string(); - output.stderr = stderr_buf.trim().to_string(); - - // 프로세스가 실패한 경우 (0이 아닌 종료 코드) - if !status.success() { - let error_msg = if !output.stderr.is_empty() { - output.stderr.clone() - } else { - format!( - "프로세스가 종료 코드 {}로 종료되었습니다", - status.code().unwrap_or(-1) - ) - }; - return Err(anyhow::anyhow!("{}", error_msg)); - } - - let result_text = output.get_output(); - - if result_text.is_empty() { - Ok("코드가 실행되었습니다.".to_string()) - } else { - Ok(result_text) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - // 테스트 간 격리를 위한 락 - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[tokio::test] - async fn test_console_log() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "console.log('Hello World');") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("Hello World")); - } - - #[tokio::test] - async fn test_variable_assignment() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "let a = 5; console.log(a);") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("5")); - } - - #[tokio::test] - async fn test_calculation() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "let a = 1; let b = 2; console.log(a + b);") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("3")); - } - - #[tokio::test] - async fn test_syntax_error() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor.execute_script("test.js", "alert('adf'(;").await; - // 문법 오류는 실행 실패를 반환해야 함 - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_multiple_statements() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script( - "test.js", - "let x = 5; let y = 3; console.log('result:', x + y);", - ) - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("result: 8")); - } - - #[tokio::test] - async fn test_multiple_console_logs() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script( - "test.js", - "console.log('First'); console.log('Second'); console.log('Third');", - ) - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("First")); - assert!(output.contains("Second")); - assert!(output.contains("Third")); - } - - #[tokio::test] - async fn test_object_logging() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "console.log({ name: 'Test', value: 42 });") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - // Node.js는 객체를 자동으로 직렬화하여 출력 - assert!(output.contains("name") || output.contains("Test")); - } -} +// 공개 API +pub use execution::ExecutionOutput; +pub use executor::NodeExecutor; +pub use node_downloader::NODE_VERSION; diff --git a/crates/node-runtime/tests/executor_test.rs b/crates/node-runtime/tests/executor_test.rs new file mode 100644 index 0000000..be0bbf3 --- /dev/null +++ b/crates/node-runtime/tests/executor_test.rs @@ -0,0 +1,102 @@ +use node_runtime::NodeExecutor; +use std::sync::Mutex; + +// 테스트 간 격리를 위한 락 +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[tokio::test] +async fn test_console_log() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log('Hello World');") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("Hello World")); +} + +#[tokio::test] +async fn test_variable_assignment() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 5; console.log(a);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("5")); +} + +#[tokio::test] +async fn test_calculation() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 1; let b = 2; console.log(a + b);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("3")); +} + +#[tokio::test] +async fn test_syntax_error() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor.execute_script("test.js", "alert('adf'(;").await; + // 문법 오류는 실행 실패를 반환해야 함 + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_multiple_statements() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "let x = 5; let y = 3; console.log('result:', x + y);", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("result: 8")); +} + +#[tokio::test] +async fn test_multiple_console_logs() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "console.log('First'); console.log('Second'); console.log('Third');", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("First")); + assert!(output.contains("Second")); + assert!(output.contains("Third")); +} + +#[tokio::test] +async fn test_object_logging() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log({ name: 'Test', value: 42 });") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + // Node.js는 객체를 자동으로 직렬화하여 출력 + assert!(output.contains("name") || output.contains("Test")); +} + From f3c486705279bc1d7af0b0e6e16e0e9e6e53927c Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 15:05:07 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor(node-runtime):=20node=5Fdownload?= =?UTF-8?q?er=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/node_downloader_test.rs: NodeDownloader 테스트 코드 분리 - node_downloader 모듈을 pub으로 공개하여 테스트에서 접근 가능하도록 변경 --- crates/node-runtime/src/execution.rs | 1 - crates/node-runtime/src/executor.rs | 1 - crates/node-runtime/src/lib.rs | 2 +- crates/node-runtime/src/node_downloader.rs | 65 ----------- .../node-runtime/src/tests/executor_test.rs | 101 ++++++++++++++++++ .../tests/node_downloader_test.rs | 66 ++++++++++++ 6 files changed, 168 insertions(+), 68 deletions(-) create mode 100644 crates/node-runtime/src/tests/executor_test.rs create mode 100644 crates/node-runtime/tests/node_downloader_test.rs diff --git a/crates/node-runtime/src/execution.rs b/crates/node-runtime/src/execution.rs index 1df36fb..1e609c4 100644 --- a/crates/node-runtime/src/execution.rs +++ b/crates/node-runtime/src/execution.rs @@ -27,4 +27,3 @@ impl ExecutionOutput { output.join("\n") } } - diff --git a/crates/node-runtime/src/executor.rs b/crates/node-runtime/src/executor.rs index ac60cfb..6653595 100644 --- a/crates/node-runtime/src/executor.rs +++ b/crates/node-runtime/src/executor.rs @@ -173,4 +173,3 @@ impl NodeExecutor { } } } - diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs index ba180de..fb942c7 100644 --- a/crates/node-runtime/src/lib.rs +++ b/crates/node-runtime/src/lib.rs @@ -1,6 +1,6 @@ mod execution; mod executor; -mod node_downloader; +pub mod node_downloader; // 공개 API pub use execution::ExecutionOutput; diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs index d8d8272..6c9e198 100644 --- a/crates/node-runtime/src/node_downloader.rs +++ b/crates/node-runtime/src/node_downloader.rs @@ -353,68 +353,3 @@ impl NodeDownloader { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_platform_info() { - let result = NodeDownloader::get_platform_info(); - assert!(result.is_ok()); - - let (os_name, arch, extension, binary_name) = result.unwrap(); - assert!(!os_name.is_empty()); - assert!(!arch.is_empty()); - assert!(!extension.is_empty()); - assert!(!binary_name.is_empty()); - - // 플랫폼별 검증 - #[cfg(target_os = "macos")] - { - assert_eq!(os_name, "darwin"); - assert!(extension == "tar.xz"); - assert_eq!(binary_name, "node"); - } - - #[cfg(target_os = "windows")] - { - assert_eq!(os_name, "win"); - assert!(extension == "zip"); - assert_eq!(binary_name, "node.exe"); - } - - #[cfg(target_os = "linux")] - { - assert_eq!(os_name, "linux"); - assert!(extension == "tar.xz"); - assert_eq!(binary_name, "node"); - } - } - - #[test] - fn test_cache_dir() { - let result = NodeDownloader::cache_dir(); - assert!(result.is_ok()); - - let cache_dir = result.unwrap(); - assert!(cache_dir.to_string_lossy().contains("executejs")); - assert!(cache_dir.to_string_lossy().contains("node-runtime")); - assert!(cache_dir.exists() || cache_dir.parent().unwrap().exists()); - } - - #[test] - fn test_node_version_constant() { - // NODE_VERSION 상수가 올바르게 정의되어 있는지 확인 - assert_eq!(NODE_VERSION, "v24.12.0"); - assert!(NODE_VERSION.starts_with('v')); - } - - #[test] - fn test_base_url() { - let url = base_url(); - assert!(url.contains("nodejs.org")); - assert!(url.contains("dist")); - assert!(url.contains(NODE_VERSION)); - assert!(url.ends_with('/')); - } -} diff --git a/crates/node-runtime/src/tests/executor_test.rs b/crates/node-runtime/src/tests/executor_test.rs new file mode 100644 index 0000000..9155324 --- /dev/null +++ b/crates/node-runtime/src/tests/executor_test.rs @@ -0,0 +1,101 @@ +use crate::executor::NodeExecutor; +use std::sync::Mutex; + +// 테스트 간 격리를 위한 락 +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[tokio::test] +async fn test_console_log() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log('Hello World');") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("Hello World")); +} + +#[tokio::test] +async fn test_variable_assignment() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 5; console.log(a);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("5")); +} + +#[tokio::test] +async fn test_calculation() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 1; let b = 2; console.log(a + b);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("3")); +} + +#[tokio::test] +async fn test_syntax_error() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor.execute_script("test.js", "alert('adf'(;").await; + // 문법 오류는 실행 실패를 반환해야 함 + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_multiple_statements() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "let x = 5; let y = 3; console.log('result:', x + y);", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("result: 8")); +} + +#[tokio::test] +async fn test_multiple_console_logs() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "console.log('First'); console.log('Second'); console.log('Third');", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("First")); + assert!(output.contains("Second")); + assert!(output.contains("Third")); +} + +#[tokio::test] +async fn test_object_logging() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log({ name: 'Test', value: 42 });") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + // Node.js는 객체를 자동으로 직렬화하여 출력 + assert!(output.contains("name") || output.contains("Test")); +} diff --git a/crates/node-runtime/tests/node_downloader_test.rs b/crates/node-runtime/tests/node_downloader_test.rs new file mode 100644 index 0000000..aae2869 --- /dev/null +++ b/crates/node-runtime/tests/node_downloader_test.rs @@ -0,0 +1,66 @@ +use node_runtime::node_downloader::{NodeDownloader, NODE_VERSION}; + +#[test] +fn test_get_platform_info() { + let result = NodeDownloader::get_platform_info(); + assert!(result.is_ok()); + + let (os_name, arch, extension, binary_name) = result.unwrap(); + assert!(!os_name.is_empty()); + assert!(!arch.is_empty()); + assert!(!extension.is_empty()); + assert!(!binary_name.is_empty()); + + // 플랫폼별 검증 + #[cfg(target_os = "macos")] + { + assert_eq!(os_name, "darwin"); + assert!(extension == "tar.xz"); + assert_eq!(binary_name, "node"); + } + + #[cfg(target_os = "windows")] + { + assert_eq!(os_name, "win"); + assert!(extension == "zip"); + assert_eq!(binary_name, "node.exe"); + } + + #[cfg(target_os = "linux")] + { + assert_eq!(os_name, "linux"); + assert!(extension == "tar.xz"); + assert_eq!(binary_name, "node"); + } +} + +#[test] +fn test_cache_dir() { + let result = NodeDownloader::cache_dir(); + assert!(result.is_ok()); + + let cache_dir = result.unwrap(); + assert!(cache_dir.to_string_lossy().contains("executejs")); + assert!(cache_dir.to_string_lossy().contains("node-runtime")); + assert!(cache_dir.exists() || cache_dir.parent().unwrap().exists()); +} + +#[test] +fn test_node_version_constant() { + // NODE_VERSION 상수가 올바르게 정의되어 있는지 확인 + assert_eq!(NODE_VERSION, "v24.12.0"); + assert!(NODE_VERSION.starts_with('v')); +} + +#[test] +fn test_base_url() { + // base_url은 private 함수이므로 직접 테스트할 수 없지만, + // NODE_VERSION을 통해 간접적으로 검증 가능 + assert_eq!(NODE_VERSION, "v24.12.0"); + let expected_url = format!("https://nodejs.org/dist/{}/", NODE_VERSION); + assert!(expected_url.contains("nodejs.org")); + assert!(expected_url.contains("dist")); + assert!(expected_url.contains(NODE_VERSION)); + assert!(expected_url.ends_with('/')); +} + From c17a3b671950c327d9a1f8c15be403951a4e65b6 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 15:05:15 +0900 Subject: [PATCH 08/10] =?UTF-8?q?chore(node-runtime):=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=90=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/tests/executor_test.rs 삭제 (올바른 위치는 tests/executor_test.rs) --- .../node-runtime/src/tests/executor_test.rs | 101 ------------------ 1 file changed, 101 deletions(-) delete mode 100644 crates/node-runtime/src/tests/executor_test.rs diff --git a/crates/node-runtime/src/tests/executor_test.rs b/crates/node-runtime/src/tests/executor_test.rs deleted file mode 100644 index 9155324..0000000 --- a/crates/node-runtime/src/tests/executor_test.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::executor::NodeExecutor; -use std::sync::Mutex; - -// 테스트 간 격리를 위한 락 -static TEST_LOCK: Mutex<()> = Mutex::new(()); - -#[tokio::test] -async fn test_console_log() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "console.log('Hello World');") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("Hello World")); -} - -#[tokio::test] -async fn test_variable_assignment() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "let a = 5; console.log(a);") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("5")); -} - -#[tokio::test] -async fn test_calculation() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "let a = 1; let b = 2; console.log(a + b);") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("3")); -} - -#[tokio::test] -async fn test_syntax_error() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor.execute_script("test.js", "alert('adf'(;").await; - // 문법 오류는 실행 실패를 반환해야 함 - assert!(result.is_err()); -} - -#[tokio::test] -async fn test_multiple_statements() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script( - "test.js", - "let x = 5; let y = 3; console.log('result:', x + y);", - ) - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("result: 8")); -} - -#[tokio::test] -async fn test_multiple_console_logs() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script( - "test.js", - "console.log('First'); console.log('Second'); console.log('Third');", - ) - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - assert!(output.contains("First")); - assert!(output.contains("Second")); - assert!(output.contains("Third")); -} - -#[tokio::test] -async fn test_object_logging() { - let _lock = TEST_LOCK.lock().unwrap(); - let executor = NodeExecutor::new().unwrap(); - let result = executor - .execute_script("test.js", "console.log({ name: 'Test', value: 42 });") - .await; - assert!(result.is_ok()); - let output = result.unwrap(); - println!("실제 출력: '{}'", output); - // Node.js는 객체를 자동으로 직렬화하여 출력 - assert!(output.contains("name") || output.contains("Test")); -} From d32acadb77882b4587da936f549f65c041611643 Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 15:05:49 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor(node-runtime):=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 테스트 코드를 tests/ 디렉토리로 분리 - 소스 파일은 비즈니스 로직만 포함하도록 정리 --- crates/node-runtime/src/node_downloader.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/node-runtime/src/node_downloader.rs b/crates/node-runtime/src/node_downloader.rs index 6c9e198..b1b42b5 100644 --- a/crates/node-runtime/src/node_downloader.rs +++ b/crates/node-runtime/src/node_downloader.rs @@ -352,4 +352,3 @@ impl NodeDownloader { Ok(()) // 체크섬 파일에 없으면 경고만 하고 계속 진행 } } - From 8352cb29dacfea486b20a657a25cbdbb2574d5df Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 13 Dec 2025 15:07:25 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore(node-runtime):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불필요한 빈 줄 제거 --- crates/node-runtime/tests/executor_test.rs | 1 - crates/node-runtime/tests/node_downloader_test.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/node-runtime/tests/executor_test.rs b/crates/node-runtime/tests/executor_test.rs index be0bbf3..c3a7151 100644 --- a/crates/node-runtime/tests/executor_test.rs +++ b/crates/node-runtime/tests/executor_test.rs @@ -99,4 +99,3 @@ async fn test_object_logging() { // Node.js는 객체를 자동으로 직렬화하여 출력 assert!(output.contains("name") || output.contains("Test")); } - diff --git a/crates/node-runtime/tests/node_downloader_test.rs b/crates/node-runtime/tests/node_downloader_test.rs index aae2869..7fd352e 100644 --- a/crates/node-runtime/tests/node_downloader_test.rs +++ b/crates/node-runtime/tests/node_downloader_test.rs @@ -63,4 +63,3 @@ fn test_base_url() { assert!(expected_url.contains(NODE_VERSION)); assert!(expected_url.ends_with('/')); } -