diff --git a/.cursorrules b/.cursorrules index 9cd4dc2..0c34212 100644 --- a/.cursorrules +++ b/.cursorrules @@ -19,7 +19,7 @@ - **Backend**: Rust, Tauri 2.0 - **JavaScript Engine**: Deno Core 0.323 (V8 기반) - **Package Manager**: pnpm -- **Linting**: oxlint (JavaScript), clippy (Rust) +- **Linting**: oxlint (JavaScript) - **Formatting**: Prettier (JavaScript), rustfmt (Rust) - **Documentation**: RSPress @@ -155,7 +155,6 @@ chore: 빌드 설정 변경 - [ ] Prettier 포맷팅 적용 - [ ] TypeScript 타입 체크 통과 - [ ] Vitest 테스트 통과 -- [ ] Rust clippy 통과 - [ ] rustfmt 포맷팅 적용 - [ ] cargo test 통과 diff --git a/Cargo.lock b/Cargo.lock index 9142d48..137c2b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,6 +1016,13 @@ version = "0.1.0" dependencies = [ "anyhow", "deno_core", + "dirs 5.0.1", + "flate2", + "futures", + "reqwest", + "serde", + "serde_json", + "tar", "tokio", "tracing", ] @@ -1163,13 +1170,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1180,7 +1208,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1482,6 +1510,18 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -2753,6 +2793,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall", ] [[package]] @@ -4247,6 +4288,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4326,10 +4378,12 @@ dependencies = [ "http-body-util", "hyper 1.7.0", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4340,6 +4394,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower 0.5.2", @@ -5222,6 +5277,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5237,7 +5303,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5288,7 +5354,7 @@ checksum = "a924b6c50fe83193f0f8b14072afa7c25b7a72752a2a73d9549b463f5fe91a38" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -6132,7 +6198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -7279,7 +7345,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", @@ -7362,6 +7428,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.2", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/crates/deno-runtime/Cargo.toml b/crates/deno-runtime/Cargo.toml index 9895d51..6576d3c 100644 --- a/crates/deno-runtime/Cargo.toml +++ b/crates/deno-runtime/Cargo.toml @@ -16,5 +16,22 @@ tracing.workspace = true # Deno Core dependencies deno_core = "0.323" +# HTTP 클라이언트 (npm 레지스트리 API) +reqwest = { version = "0.12", features = ["rustls-tls", "json"] } + +# Tarball 압축 해제 +tar = "0.4" +flate2 = "1.0" + +# JSON 파싱 (npm 레지스트리 응답) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# 파일 경로 처리 +dirs = "5.0" + +# Future 유틸리티 +futures = "0.3" + [dev-dependencies] tokio.workspace = true diff --git a/crates/deno-runtime/src/bootstrap.js b/crates/deno-runtime/src/bootstrap.js index 7300fa3..83d8ee2 100644 --- a/crates/deno-runtime/src/bootstrap.js +++ b/crates/deno-runtime/src/bootstrap.js @@ -147,3 +147,24 @@ if (typeof globalThis.module === 'undefined') { if (typeof globalThis.exports === 'undefined') { globalThis.exports = globalThis.module.exports; } + +// Node.js process 객체 정의 (npm 모듈 호환성) +if (typeof globalThis.process === 'undefined') { + globalThis.process = { + env: { + NODE_ENV: 'development', + }, + version: 'v20.0.0', + versions: { + node: '20.0.0', + v8: '10.2.0', + }, + platform: 'darwin', + arch: 'x64', + cwd: () => '/', + nextTick: (callback) => { + // setTimeout으로 시뮬레이션 + setTimeout(callback, 0); + }, + }; +} diff --git a/crates/deno-runtime/src/lib.rs b/crates/deno-runtime/src/lib.rs index 8167c33..e37918e 100644 --- a/crates/deno-runtime/src/lib.rs +++ b/crates/deno-runtime/src/lib.rs @@ -1,11 +1,23 @@ +use anyhow::Error as AnyhowError; use anyhow::Result; +use deno_core::error::type_error; use deno_core::error::AnyError; -use deno_core::{extension, op2, FsModuleLoader, JsRuntime, RuntimeOptions}; -use std::collections::VecDeque; +use deno_core::{ + extension, op2, FastString, FsModuleLoader, JsRuntime, ModuleLoadResponse, ModuleLoader, + ModuleSource, ModuleSourceCode, ModuleSpecifier, ModuleType, RequestedModuleType, + ResolutionKind, RuntimeOptions, +}; +use futures::FutureExt; +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::sync::Mutex; +mod npm_resolver; +pub use npm_resolver::NpmResolver; + /// JavaScript 실행 결과를 저장하는 구조체 #[derive(Debug, Clone)] pub struct ExecutionOutput { @@ -99,6 +111,289 @@ extension!( ops = [op_console_log, op_alert, op_custom_print], ); +/// npm 패키지를 지원하는 모듈 로더 +pub struct NpmModuleLoader { + fs_loader: FsModuleLoader, + npm_resolver: Arc>, + /// npm: URL과 실제 파일 경로 매핑 + npm_path_map: Arc>>, +} + +impl NpmModuleLoader { + pub fn new() -> Result { + Ok(Self { + fs_loader: FsModuleLoader, + npm_resolver: Arc::new(Mutex::new(NpmResolver::new()?)), + npm_path_map: Arc::new(Mutex::new(HashMap::new())), + }) + } +} + +impl ModuleLoader for NpmModuleLoader { + fn resolve( + &self, + specifier: &str, + referrer: &str, + kind: ResolutionKind, + ) -> Result { + eprintln!( + "[NpmModuleLoader::resolve] specifier: {}, referrer: {}, kind: {:?}", + specifier, referrer, kind + ); + + // npm: 프로토콜 처리 + if specifier.starts_with("npm:") { + eprintln!( + "[NpmModuleLoader::resolve] npm: 프로토콜 감지: {}", + specifier + ); + // npm: 프로토콜을 그대로 유지하여 load에서 처리 + ModuleSpecifier::parse(specifier).map_err(|e| { + eprintln!("[NpmModuleLoader::resolve] 모듈 스펙 해석 실패: {}", e); + let msg = format!("모듈 스펙 해석 실패: {}", e); + type_error(msg).into() + }) + } else { + // npm 패키지 내부의 상대 경로 처리 + // referrer가 npm: 프로토콜이면 실제 파일 경로로 변환 + let actual_referrer = if referrer.starts_with("npm:") { + eprintln!( + "[NpmModuleLoader::resolve] npm 패키지 내부 상대 경로 감지: {} (referrer: {})", + specifier, referrer + ); + + // npm: URL과 실제 파일 경로 매핑에서 찾기 + let path_map = self.npm_path_map.lock().unwrap(); + if let Some(actual_path) = path_map.get(referrer) { + // 실제 파일 경로를 file:// URL로 변환 + // ModuleSpecifier를 사용하여 올바른 URL 형식으로 변환 + let file_url = match ModuleSpecifier::from_file_path(actual_path) { + Ok(url) => { + eprintln!( + "[NpmModuleLoader::resolve] 실제 경로로 변환: {} -> {}", + referrer, + url.as_str() + ); + url.as_str().to_string() + } + Err(e) => { + eprintln!( + "[NpmModuleLoader::resolve] 파일 경로를 URL로 변환 실패: {:?}", + e + ); + // 폴백: file:// 절대 경로 형식 사용 + let path_str = actual_path.to_string_lossy(); + format!("file://{}", path_str) + } + }; + file_url + } else { + eprintln!( + "[NpmModuleLoader::resolve] 경로 매핑을 찾을 수 없음: {}", + referrer + ); + // 매핑이 없으면 원래 referrer 사용 (에러 발생 가능) + referrer.to_string() + } + } else { + referrer.to_string() + }; + + eprintln!( + "[NpmModuleLoader::resolve] 일반 파일 시스템 모듈로 처리 (referrer: {})", + actual_referrer + ); + // 일반 파일 시스템 모듈 + self.fs_loader.resolve(specifier, &actual_referrer, kind) + } + } + + fn load( + &self, + module_specifier: &ModuleSpecifier, + maybe_referrer: Option<&ModuleSpecifier>, + is_dyn_import: bool, + requested_module_type: RequestedModuleType, + ) -> ModuleLoadResponse { + let specifier_str = module_specifier.as_str(); + let npm_resolver = self.npm_resolver.clone(); + let npm_path_map = self.npm_path_map.clone(); + let specifier = module_specifier.clone(); + + eprintln!( + "[NpmModuleLoader::load] specifier: {}, is_dyn_import: {}", + specifier_str, is_dyn_import + ); + + // npm: 프로토콜 처리 + if specifier_str.starts_with("npm:") { + eprintln!( + "[NpmModuleLoader::load] npm: 프로토콜 감지, 패키지 다운로드 시작: {}", + specifier_str + ); + let package_spec = &specifier_str[4..]; + + // 패키지명과 버전 파싱 + let (package_name, version) = if let Some(at_pos) = package_spec.rfind('@') { + // 스코프 패키지 처리 (@scope/package@version) + if package_spec.starts_with('@') { + // @scope/package@version 형식 + let after_first_at = &package_spec[1..]; + if let Some(second_at_pos) = after_first_at.rfind('@') { + // second_at_pos는 after_first_at 기준이므로 package_spec 기준으로는 +1 필요 + let scope_and_name = &package_spec[..=(second_at_pos + 1)]; + let version = &package_spec[second_at_pos + 2..]; + (scope_and_name.to_string(), Some(version.to_string())) + } else { + // @scope/package (버전 없음) + (package_spec.to_string(), None) + } + } else { + // package@version + let (name, version) = package_spec.split_at(at_pos); + (name.to_string(), Some(version[1..].to_string())) + } + } else { + (package_spec.to_string(), None) + }; + + // 비동기 로드 + // 필요한 데이터를 먼저 복사하고 락 해제 + let cache_dir = npm_resolver.lock().unwrap().cache_dir().to_path_buf(); + let registry_url = npm_resolver.lock().unwrap().registry_url().to_string(); + + let fut = async move { + eprintln!( + "[NpmModuleLoader::load] 패키지명: {}, 버전: {:?}", + package_name, version + ); + + // 패키지 다운로드 및 설치 (독립적인 리졸버 생성) + eprintln!("[NpmModuleLoader::load] NpmResolver 생성 중..."); + let resolver = + NpmResolver::with_cache_dir(cache_dir, registry_url).map_err(|e| { + eprintln!("[NpmModuleLoader::load] 리졸버 생성 실패: {}", e); + let msg = format!("리졸버 생성 실패: {}", e); + type_error(msg) + })?; + eprintln!("[NpmModuleLoader::load] 리졸버 생성 완료"); + + eprintln!( + "[NpmModuleLoader::load] install_package 호출: {}@{:?}", + package_name, + version.as_deref() + ); + let package_dir = resolver + .install_package(&package_name, version.as_deref()) + .await + .map_err(|e| { + eprintln!("[NpmModuleLoader::load] npm 패키지 다운로드 실패: {}", e); + let msg = format!("npm 패키지 다운로드 실패: {}", e); + type_error(msg) + })?; + eprintln!( + "[NpmModuleLoader::load] 패키지 다운로드 완료: {:?}", + package_dir + ); + + // 진입점 찾기 + eprintln!("[NpmModuleLoader::load] 진입점 찾기 시작..."); + let entry_point = resolver.find_entry_point(&package_dir).map_err(|e| { + eprintln!("[NpmModuleLoader::load] 진입점 찾기 실패: {}", e); + let msg = format!("진입점 찾기 실패: {}", e); + type_error(msg) + })?; + eprintln!("[NpmModuleLoader::load] 진입점: {:?}", entry_point); + + // 타입 정의 파일 찾기 + eprintln!("[NpmModuleLoader::load] 타입 정의 파일 찾기 시작..."); + let type_def = resolver.find_type_definitions(&package_dir).unwrap_or(None); + if let Some(type_def_path) = &type_def { + eprintln!( + "[NpmModuleLoader::load] 타입 정의 파일 발견: {:?}", + type_def_path + ); + } else { + eprintln!("[NpmModuleLoader::load] 타입 정의 파일 없음"); + } + + // npm: URL과 실제 파일 경로 매핑 저장 + let specifier_str_for_map = specifier.as_str().to_string(); + { + let mut path_map = npm_path_map.lock().unwrap(); + path_map.insert(specifier_str_for_map.clone(), entry_point.clone()); + eprintln!( + "[NpmModuleLoader::load] 경로 매핑 저장: {} -> {:?}", + specifier_str_for_map, entry_point + ); + } + + // 파일 읽기 + eprintln!("[NpmModuleLoader::load] 파일 읽기 시작..."); + let code = fs::read_to_string(&entry_point).map_err(|e| { + eprintln!("[NpmModuleLoader::load] 파일 읽기 실패: {}", e); + let msg = format!("파일 읽기 실패: {}", e); + type_error(msg) + })?; + eprintln!( + "[NpmModuleLoader::load] 파일 읽기 완료, 코드 길이: {} bytes", + code.len() + ); + + // 파일 확장자에 따라 ModuleType 결정 + // Deno Core는 TypeScript를 자동으로 처리하므로 JavaScript로 설정 + // 실제 TypeScript 파일은 런타임에서 자동 변환됨 + let file_ext = entry_point + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + let module_type = match file_ext { + "ts" | "tsx" | "mts" | "cts" => { + eprintln!( + "[NpmModuleLoader::load] TypeScript 모듈로 감지 (확장자: {})", + file_ext + ); + // Deno Core는 TypeScript도 JavaScript로 처리 가능 + ModuleType::JavaScript + } + "jsx" => { + eprintln!("[NpmModuleLoader::load] JSX 모듈로 감지"); + ModuleType::JavaScript + } + _ => { + eprintln!("[NpmModuleLoader::load] JavaScript 모듈로 감지"); + ModuleType::JavaScript + } + }; + + // ModuleSource 생성 + eprintln!("[NpmModuleLoader::load] ModuleSource 생성 중..."); + let module_code = ModuleSourceCode::String(FastString::from(code)); + let module_source = ModuleSource::new( + module_type, + module_code, + &specifier, + None, // code_cache + ); + eprintln!("[NpmModuleLoader::load] ModuleSource 생성 완료"); + + Ok(module_source) + }; + + ModuleLoadResponse::Async(fut.boxed()) + } else { + // 일반 파일 시스템 모듈 + self.fs_loader.load( + module_specifier, + maybe_referrer, + is_dyn_import, + requested_module_type, + ) + } + } +} + /// JavaScript 실행기 (Deno Core 기반) pub struct DenoExecutor { output_buffer: Arc>, @@ -133,9 +428,19 @@ impl DenoExecutor { // 별도 스레드에서 Deno Core 실행 (Send 트레이트 문제 해결) let result = tokio::task::spawn_blocking(move || { + // 커스텀 모듈 로더 생성 (npm 지원) + let module_loader = match NpmModuleLoader::new() { + Ok(loader) => Rc::new(loader) as Rc, + Err(e) => { + // npm 리졸버 생성 실패 시 기본 로더 사용 + eprintln!("npm 모듈 로더 초기화 실패 (기본 로더 사용): {}", e); + Rc::new(FsModuleLoader) as Rc + } + }; + // JsRuntime 생성 let mut js_runtime = JsRuntime::new(RuntimeOptions { - module_loader: Some(Rc::new(FsModuleLoader)), + module_loader: Some(module_loader), extensions: vec![executejs_runtime::init_ops()], ..Default::default() }); @@ -147,14 +452,59 @@ impl DenoExecutor { } // 코드 실행 - let result = js_runtime.execute_script("[executejs:user_code]", code)?; - - // 이벤트 루프 실행 (Promise 처리) - 블로킹 방식으로 변경 + eprintln!( + "[DenoExecutor] 코드 실행 시작, 코드 길이: {} bytes", + code.len() + ); + eprintln!( + "[DenoExecutor] 코드 내용 (처음 200자): {}", + &code.chars().take(200).collect::() + ); + + // ES 모듈 import 구문이 있는지 확인 + let has_import = code.contains("import ") || code.contains("export "); + eprintln!("[DenoExecutor] ES 모듈 구문 감지: {}", has_import); + + // 이벤트 루프 실행을 위한 런타임 핸들 let rt = tokio::runtime::Handle::current(); - rt.block_on(async { js_runtime.run_event_loop(Default::default()).await })?; - // 결과 처리 - let _ = result; + if has_import { + // ES 모듈로 실행 + eprintln!("[DenoExecutor] ES 모듈로 실행 시도..."); + let specifier = ModuleSpecifier::parse("file:///executejs/user_code.mjs") + .map_err(|e| anyhow::anyhow!("{}", e))?; + + eprintln!( + "[DenoExecutor] load_main_es_module_from_code 호출: {}", + specifier + ); + let module_id = rt + .block_on(async { + js_runtime + .load_main_es_module_from_code(&specifier, code.clone()) + .await + }) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + eprintln!("[DenoExecutor] 모듈 로드 완료, ModuleId: {}", module_id); + + // 모듈 평가 (비동기) + eprintln!("[DenoExecutor] mod_evaluate 호출..."); + rt.block_on(async { js_runtime.mod_evaluate(module_id).await }) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + eprintln!("[DenoExecutor] mod_evaluate 완료"); + } else { + // 일반 스크립트로 실행 + eprintln!("[DenoExecutor] 일반 스크립트로 실행..."); + let _result = js_runtime.execute_script("[executejs:user_code]", code)?; + eprintln!("[DenoExecutor] execute_script 완료"); + } + + // 이벤트 루프 실행 (Promise 처리 및 모듈 로딩 완료 대기) + eprintln!("[DenoExecutor] 이벤트 루프 실행 시작..."); + rt.block_on(async { js_runtime.run_event_loop(Default::default()).await })?; + eprintln!("[DenoExecutor] 이벤트 루프 완료"); // 출력 버퍼에서 결과 가져오기 let output = output_buffer.lock().unwrap(); diff --git a/crates/deno-runtime/src/npm_resolver.rs b/crates/deno-runtime/src/npm_resolver.rs new file mode 100644 index 0000000..d10f408 --- /dev/null +++ b/crates/deno-runtime/src/npm_resolver.rs @@ -0,0 +1,342 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// npm 레지스트리 메타데이터 응답 +#[derive(Debug, Deserialize)] +struct NpmRegistryResponse { + #[serde(rename = "dist-tags")] + dist_tags: DistTags, + versions: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct DistTags { + latest: String, +} + +/// 패키지 버전 메타데이터 +#[derive(Debug, Deserialize)] +struct PackageVersion { + version: String, + dist: Dist, +} + +#[derive(Debug, Deserialize)] +struct Dist { + tarball: String, +} + +/// npm 패키지 리졸버 +pub struct NpmResolver { + cache_dir: PathBuf, + registry_url: String, +} + +impl NpmResolver { + pub fn new() -> Result { + // 캐시 디렉토리 설정 (OS별 기본 경로) + let cache_dir = dirs::cache_dir() + .context("캐시 디렉토리를 찾을 수 없습니다")? + .join("executejs") + .join("npm"); + + // 캐시 디렉토리 생성 + fs::create_dir_all(&cache_dir).context("캐시 디렉토리를 생성할 수 없습니다")?; + + Ok(Self { + cache_dir, + registry_url: "https://registry.npmjs.org".to_string(), + }) + } + + /// 캐시 디렉토리와 레지스트리 URL을 지정하여 생성 + pub fn with_cache_dir(cache_dir: PathBuf, registry_url: String) -> Result { + // 캐시 디렉토리 생성 + fs::create_dir_all(&cache_dir).context("캐시 디렉토리를 생성할 수 없습니다")?; + + Ok(Self { + cache_dir, + registry_url, + }) + } + + /// 캐시 디렉토리 경로 반환 + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + + /// 레지스트리 URL 반환 + pub fn registry_url(&self) -> &str { + &self.registry_url + } + + /// 패키지 다운로드 및 설치 + pub async fn install_package( + &self, + package_name: &str, + version: Option<&str>, + ) -> Result { + eprintln!( + "[NpmResolver::install_package] 시작: package_name={}, version={:?}", + package_name, version + ); + + // 패키지 버전 결정 + let version = match version { + Some(v) => { + eprintln!("[NpmResolver::install_package] 버전 지정됨: {}", v); + v.to_string() + } + None => { + eprintln!("[NpmResolver::install_package] 최신 버전 조회 중..."); + let latest = self.get_latest_version(package_name).await?; + eprintln!("[NpmResolver::install_package] 최신 버전: {}", latest); + latest + } + }; + + // 캐시 경로 + let package_dir = self.cache_dir.join(package_name).join(&version); + eprintln!( + "[NpmResolver::install_package] 캐시 경로: {:?}", + package_dir + ); + + // 이미 설치되어 있으면 스킵 + if package_dir.exists() { + eprintln!("[NpmResolver::install_package] 캐시 디렉토리 존재 확인 중..."); + // package.json이 존재하는지 확인 + let package_json_path = package_dir.join("package").join("package.json"); + if package_json_path.exists() { + eprintln!( + "[NpmResolver::install_package] 캐시된 패키지 사용: {:?}", + package_dir + ); + return Ok(package_dir); + } + eprintln!("[NpmResolver::install_package] package.json 없음, 재다운로드 필요"); + } + + // 패키지 메타데이터 가져오기 + eprintln!("[NpmResolver::install_package] tarball URL 조회 중..."); + let tarball_url = self.get_tarball_url(package_name, &version).await?; + eprintln!( + "[NpmResolver::install_package] tarball URL: {}", + tarball_url + ); + + // tarball 다운로드 + eprintln!("[NpmResolver::install_package] tarball 다운로드 시작..."); + let tarball_data = self.download_tarball(&tarball_url).await?; + eprintln!( + "[NpmResolver::install_package] tarball 다운로드 완료: {} bytes", + tarball_data.len() + ); + + // 기존 디렉토리 삭제 후 재생성 + if package_dir.exists() { + fs::remove_dir_all(&package_dir) + .context("기존 패키지 디렉토리를 삭제할 수 없습니다")?; + } + fs::create_dir_all(&package_dir).context("패키지 디렉토리를 생성할 수 없습니다")?; + + // 압축 해제 + eprintln!("[NpmResolver::install_package] tarball 압축 해제 중..."); + self.extract_tarball(&tarball_data, &package_dir)?; + eprintln!("[NpmResolver::install_package] 압축 해제 완료"); + + eprintln!( + "[NpmResolver::install_package] 패키지 설치 완료: {:?}", + package_dir + ); + Ok(package_dir) + } + + /// 최신 버전 가져오기 + async fn get_latest_version(&self, package_name: &str) -> Result { + let url = format!("{}/{}", self.registry_url, package_name); + let response = reqwest::get(&url).await?; + let metadata: NpmRegistryResponse = response.json().await?; + Ok(metadata.dist_tags.latest) + } + + /// tarball URL 가져오기 + async fn get_tarball_url(&self, package_name: &str, version: &str) -> Result { + let url = format!("{}/{}", self.registry_url, package_name); + let response = reqwest::get(&url).await?; + let metadata: serde_json::Value = response.json().await?; + + let version_data = metadata["versions"][version] + .as_object() + .context("패키지 버전을 찾을 수 없습니다")?; + + let tarball_url = version_data["dist"]["tarball"] + .as_str() + .context("tarball URL을 찾을 수 없습니다")?; + + Ok(tarball_url.to_string()) + } + + /// tarball 다운로드 + async fn download_tarball(&self, url: &str) -> Result> { + let response = reqwest::get(url).await?; + let bytes = response.bytes().await?; + Ok(bytes.to_vec()) + } + + /// tarball 압축 해제 + fn extract_tarball(&self, data: &[u8], target_dir: &Path) -> Result<()> { + // 디렉토리 생성 + fs::create_dir_all(target_dir).context("타겟 디렉토리를 생성할 수 없습니다")?; + + // gzip 압축 해제 + let mut decoder = flate2::read::GzDecoder::new(data); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + + // tar 압축 해제 + let mut archive = tar::Archive::new(&decompressed[..]); + archive.unpack(target_dir)?; + + Ok(()) + } + + /// 패키지의 진입점 파일 찾기 (ESM 우선: exports.import > module > main) + pub fn find_entry_point(&self, package_dir: &Path) -> Result { + let package_json_path = package_dir.join("package").join("package.json"); + eprintln!( + "[NpmResolver::find_entry_point] package.json 경로: {:?}", + package_json_path + ); + + let package_json_content = + fs::read_to_string(&package_json_path).context("package.json을 읽을 수 없습니다")?; + eprintln!("[NpmResolver::find_entry_point] package.json 읽기 완료"); + + let package_json: serde_json::Value = serde_json::from_str(&package_json_content)?; + eprintln!("[NpmResolver::find_entry_point] package.json 파싱 완료"); + + // ESM 우선순위로 찾기: exports.import > module > exports.default > main + let entry_point = package_json["exports"] + .as_object() + .and_then(|e| e.get(".")) + .and_then(|e| { + e.as_object().and_then(|e| { + // exports["."].import.default 우선 확인 (ESM) + e.get("import").and_then(|i| { + i.as_object() + .and_then(|i| i.get("default")) + .and_then(|d| d.as_str()) + .or_else(|| { + // exports["."].import가 직접 문자열인 경우 + i.as_str() + }) + }) + }) + }) + .or_else(|| package_json["module"].as_str()) // module 필드 (ESM) + .or_else(|| { + // exports["."]가 직접 문자열인 경우 + package_json["exports"] + .as_object() + .and_then(|e| e.get(".")) + .and_then(|e| e.as_str()) + }) + .or_else(|| { + // exports["."].require (CommonJS, 폴백) + package_json["exports"] + .as_object() + .and_then(|e| e.get(".")) + .and_then(|e| { + e.as_object().and_then(|e| e.get("require")).and_then(|r| { + r.as_object() + .and_then(|r| r.get("default")) + .and_then(|d| d.as_str()) + .or_else(|| r.as_str()) + }) + }) + }) + .or_else(|| package_json["main"].as_str()) // main (CommonJS, 최종 폴백) + .unwrap_or("index.js"); // 기본값 + + eprintln!( + "[NpmResolver::find_entry_point] 진입점 이름: {}", + entry_point + ); + + let full_path = package_dir.join("package").join(entry_point); + eprintln!("[NpmResolver::find_entry_point] 전체 경로: {:?}", full_path); + eprintln!( + "[NpmResolver::find_entry_point] 파일 존재 여부: {}", + full_path.exists() + ); + + Ok(full_path) + } + + /// 패키지의 타입 정의 파일 찾기 (package.json의 types 또는 typings 필드) + pub fn find_type_definitions(&self, package_dir: &Path) -> Result> { + let package_json_path = package_dir.join("package").join("package.json"); + eprintln!( + "[NpmResolver::find_type_definitions] package.json 경로: {:?}", + package_json_path + ); + + if !package_json_path.exists() { + eprintln!("[NpmResolver::find_type_definitions] package.json 없음"); + return Ok(None); + } + + let package_json_content = + fs::read_to_string(&package_json_path).context("package.json을 읽을 수 없습니다")?; + let package_json: serde_json::Value = serde_json::from_str(&package_json_content)?; + + // types 또는 typings 필드 확인 + let type_def = package_json["types"] + .as_str() + .or_else(|| package_json["typings"].as_str()); + + if let Some(type_def_path) = type_def { + eprintln!( + "[NpmResolver::find_type_definitions] 타입 정의 경로 발견: {}", + type_def_path + ); + let full_path = package_dir.join("package").join(type_def_path); + eprintln!( + "[NpmResolver::find_type_definitions] 전체 경로: {:?}", + full_path + ); + eprintln!( + "[NpmResolver::find_type_definitions] 파일 존재 여부: {}", + full_path.exists() + ); + + if full_path.exists() { + Ok(Some(full_path)) + } else { + eprintln!("[NpmResolver::find_type_definitions] 타입 정의 파일이 존재하지 않음"); + Ok(None) + } + } else { + eprintln!("[NpmResolver::find_type_definitions] types/typings 필드 없음"); + // @types/ 패키지 자동 검색 (예: lodash -> @types/lodash) + let package_name = package_json["name"].as_str().unwrap_or(""); + + // 스코프가 없는 패키지만 @types 검색 + if !package_name.starts_with('@') && !package_name.is_empty() { + let types_package = format!("@types/{}", package_name); + eprintln!( + "[NpmResolver::find_type_definitions] @types 패키지 검색 시도: {}", + types_package + ); + // @types 패키지는 별도로 설치해야 하므로 여기서는 로그만 남김 + // 실제로는 npm: 프로토콜을 통해 로드 가능 + } + + Ok(None) + } + } +} diff --git a/docs/docs/_meta.json b/docs/docs/_meta.json index c6e6c85..66d092e 100644 --- a/docs/docs/_meta.json +++ b/docs/docs/_meta.json @@ -5,8 +5,8 @@ "activeMatch": "/guide/" }, { - "text": "API 참조", - "link": "/api/commands", - "activeMatch": "/api/" + "text": "개발하기", + "link": "/dev/development", + "activeMatch": "/dev/" } ] diff --git a/docs/docs/dev/_meta.json b/docs/docs/dev/_meta.json new file mode 100644 index 0000000..fe17cc2 --- /dev/null +++ b/docs/docs/dev/_meta.json @@ -0,0 +1,2 @@ +["development", "api"] + diff --git a/docs/docs/api/commands.mdx b/docs/docs/dev/api/commands.mdx similarity index 100% rename from docs/docs/api/commands.mdx rename to docs/docs/dev/api/commands.mdx diff --git a/docs/docs/guide/development.mdx b/docs/docs/dev/development.mdx similarity index 79% rename from docs/docs/guide/development.mdx rename to docs/docs/dev/development.mdx index db81cc6..674c226 100644 --- a/docs/docs/guide/development.mdx +++ b/docs/docs/dev/development.mdx @@ -172,12 +172,41 @@ src/ - **프레임워크**: Tauri 2.0 - **JavaScript 엔진**: Deno Core 0.323 (V8 기반) +- **npm 모듈 지원**: npm 레지스트리에서 패키지를 직접 다운로드 및 사용 +- **CommonJS 변환**: CommonJS 패키지를 ES 모듈로 자동 변환(지원 예정) +- **UMD 변환**: CommonJS 패키지를 ES 모듈로 자동 변환(지원 예정) - **테스팅**: cargo test - **린팅**: rustfmt - **로깅**: tracing +#### npm 모듈 로딩 아키텍처 + +ExecuteJS는 커스텀 `ModuleLoader`를 구현하여 npm 패키지를 지원합니다: + +1. **NpmModuleLoader**: `npm:` 프로토콜을 처리하는 모듈 로더 +2. **NpmResolver**: npm 레지스트리에서 패키지를 다운로드하고 캐시 +3. **CommonJS 변환**: `swc`를 통해 CommonJS를 ES 모듈로 변환(지원 예정) +4. **UMD 변환**: `swc`를 통해 UMD ES 모듈로 변환(지원 예정) + +``` +crates/deno-runtime/src/ +├── lib.rs # NpmModuleLoader 구현 +└── npm_resolver.rs # npm 패키지 다운로드 및 캐시 관리 +``` + +**주요 기능**: + +- npm 레지스트리 API를 통한 패키지 다운로드 +- 로컬 캐시를 통한 빠른 재사용 +- CommonJS, UMD → ES Module 자동 변환 (지원 예정) +- 패키지 내부 상대 경로 import 지원 + ### 모노레포 구조 - **pnpm 워크스페이스**: Node.js 패키지 관리 - **Cargo 워크스페이스**: Rust 크레이트 관리 - **조건부 CI**: 폴더별 변경사항에 따른 워크플로우 실행 + +## 다음 단계 + +- [API 참조](/dev/api/commands)에서 사용 가능한 모든 Tauri 명령어를 확인하세요. diff --git a/docs/docs/guide/_meta.json b/docs/docs/guide/_meta.json index f30fef7..209ca5f 100644 --- a/docs/docs/guide/_meta.json +++ b/docs/docs/guide/_meta.json @@ -1 +1,5 @@ -["getting-started", "development", "live-demo"] +[ + "getting-started", + "npm-modules", + "live-demo" +] diff --git a/docs/docs/guide/getting-started.mdx b/docs/docs/guide/getting-started.mdx index 2d2f843..f5abc8d 100644 --- a/docs/docs/guide/getting-started.mdx +++ b/docs/docs/guide/getting-started.mdx @@ -58,7 +58,18 @@ executeJS/ └── ... ``` +## npm 모듈 사용하기 + +ExecuteJS는 npm 레지스트리에서 패키지를 직접 다운로드하고 사용할 수 있습니다. + +```javascript +import _ from 'npm:lodash'; +console.log(_.map([1, 2, 3], (x) => x * 2)); +``` + +자세한 내용은 [npm 모듈 사용하기](/guide/npm-modules) 가이드를 참조하세요. + ## 다음 단계 -- [API 참조](/api/commands)에서 사용 가능한 모든 명령어를 확인하세요. -- [개발 가이드](/guide/development)에서 프로젝트 개발 방법을 알아보세요. +- [npm 모듈 사용하기](/guide/npm-modules)에서 npm 패키지 사용 방법을 알아보세요 +- [개발하기](/dev/development)에서 프로젝트 개발 방법을 알아보세요 diff --git a/docs/docs/guide/npm-modules.mdx b/docs/docs/guide/npm-modules.mdx new file mode 100644 index 0000000..70fad71 --- /dev/null +++ b/docs/docs/guide/npm-modules.mdx @@ -0,0 +1,96 @@ +# npm 모듈 사용하기 + +ExecuteJS는 npm 레지스트리에서 패키지를 직접 다운로드하고 사용할 수 있습니다. + +## 설치 + +ExecuteJS를 아직 설치하지 않으셨다면, [GitHub 릴리즈](https://github.com/ohah/executeJS/releases)에서 최신 버전을 다운로드하여 설치하세요. + +## 기본 사용법 + +### npm 패키지 import + +`npm:` 프로토콜을 사용하여 npm 패키지를 import할 수 있습니다. + +```javascript +import _ from 'npm:lodash'; + +console.log(_.map([1, 2, 3], (x) => x * 2)); +// 출력: [2, 4, 6] +``` + +### 버전 지정 + +특정 버전을 지정할 수도 있습니다. + +```javascript +import lodash from 'npm:lodash@4.17.21'; +``` + +### 스코프 패키지 + +`@scope/package` 형식의 스코프 패키지도 지원합니다. + +```javascript +import { something } from 'npm:@some-scope/package'; +``` + +## 지원되는 패키지 형식 + +ExecuteJS는 **ES Module (ESM) 형식의 패키지**를 지원합니다. + +일부 CommonJS나 UMD 형식의 패키지도 사용할 수 있지만, 완벽하게 동작하지 않을 수 있습니다. + +## 작동 방식 + +1. **패키지 다운로드**: npm 레지스트리 API를 통해 패키지를 다운로드합니다. +2. **로컬 캐시**: 다운로드한 패키지는 로컬 캐시에 저장되어 재사용됩니다. +3. **모듈 로딩**: Deno Core를 통해 모듈을 로드하고 실행합니다. + +## 캐시 위치 + +다운로드한 패키지는 다음 위치에 캐시됩니다: + +- **macOS**: `~/Library/Caches/executejs/npm/` +- **Windows**: `%LOCALAPPDATA%/executejs/npm/` + +## 예제 + +### Lodash 사용하기 + +```javascript +import _ from 'npm:lodash'; + +const data = [1, 2, 3, 4, 5]; +const result = _.map(data, (x) => x * 2); +console.log('Doubled:', result); +// 출력: Doubled: [2, 4, 6, 8, 10] +``` + +### 날짜 처리 (date-fns) + +```javascript +import { format, addDays } from 'npm:date-fns'; + +const now = new Date(); +console.log(format(now, 'yyyy-MM-dd HH:mm:ss')); + +// 날짜 계산 +const tomorrow = addDays(now, 1); +console.log(format(tomorrow, 'yyyy-MM-dd')); +``` + +## 문제 해결 + +### 패키지 로드 실패 + +패키지가 로드되지 않는 경우: + +1. **인터넷 연결 확인**: npm 레지스트리에 접근 가능한지 확인 +2. **패키지 이름 확인**: 패키지 이름이 정확한지 확인 +3. **버전 확인**: 지정한 버전이 존재하는지 확인 + +## 다음 단계 + +- [시작하기](/guide/getting-started)에서 기본 사용법을 확인하세요 +- [개발하기](/dev/development)에서 프로젝트 개발 방법을 알아보세요 diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index dc2b2ae..171dcb6 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -18,6 +18,8 @@ hero: - 🚀 **Tauri 기반**: Rust 백엔드와 React 프론트엔드 - 🔒 **안전한 실행**: 샌드박스 환경에서 JavaScript 실행 +- 📦 **npm 모듈 지원**: npm 레지스트리에서 패키지를 직접 사용 +- 🔄 **CommonJS 변환**: CommonJS 패키지를 자동으로 ES 모듈로 변환 - 📝 **실행 기록**: 코드 실행 이력 관리 - 💾 **코드 저장/로드**: 자주 사용하는 코드 저장 - 🎨 **모던 UI**: 직관적이고 반응형 사용자 인터페이스 @@ -29,4 +31,4 @@ hero: - **Backend**: Rust, Tauri 2.0 - **Testing**: Vitest, Testing Library - **Linting**: oxlint, Prettier -- **Documentation**: RSPress \ No newline at end of file +- **Documentation**: RSPress