feat: typescript support

This commit is contained in:
zhom
2025-07-25 04:23:49 +04:00
parent 7b2bda3f7b
commit 3743747e0e
6 changed files with 720 additions and 32 deletions
+191 -21
View File
@@ -25,32 +25,34 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option<PathBuf>,
let pkg_json = project_path.join("package.json");
anyhow::ensure!(pkg_json.exists(), "package.json not found in {}", project_path.display());
// 2. Read `package.json` for name/version (best-effort fall back if absent).
let (app_name, app_version) = {
let content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?;
let value: Value = serde_json::from_str(&content).context("Failed to parse package.json")?;
(
value["name"].as_str().unwrap_or("app").to_string(),
value["version"].as_str().unwrap_or("0.0.0").to_string(),
)
};
// 2. Read `package.json` for name/version and detect project structure
let package_content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?;
let package_value: Value = serde_json::from_str(&package_content).context("Failed to parse package.json")?;
let (app_name, app_version) = (
package_value["name"].as_str().unwrap_or("app").to_string(),
package_value["version"].as_str().unwrap_or("0.0.0").to_string(),
);
// 3. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1).
// 3. Determine the correct source directory to bundle
let source_dir = determine_source_directory(&project_path, &package_value)?;
// 4. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1).
let node_version = detect_node_version(&project_path).unwrap_or_else(|_| "22.17.1".into());
println!(
"Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}",
plat = Platform::current()
);
if source_dir != project_path {
println!("Using source directory: {}", source_dir.display());
}
// 4. Resolve output path.
let output_path = output_path.unwrap_or_else(|| {
let ext = if Platform::current().is_windows() { ".exe" } else { "" };
let base_name = custom_name.as_ref().unwrap_or(&app_name);
PathBuf::from(format!("{base_name}{ext}"))
});
// 5. Resolve output path with collision handling
let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?;
// 5. Ensure portable Node binary is available.
// 6. Ensure portable Node binary is available.
let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?;
let node_executable = node_downloader.ensure_node_binary().await?;
let node_root = node_executable
@@ -59,21 +61,45 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option<PathBuf>,
.parent()
.unwrap_or_else(|| panic!("Unexpected node layout for {}", node_executable.display()));
// 6. Create an in-memory zip archive containing `/app` and `/node` directories.
// 7. Create an in-memory zip archive containing `/app` and `/node` directories.
let mut zip_data: Vec<u8> = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data));
let opts: zip::write::FileOptions<'static, ()> = zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
// Copy project directory.
add_dir_to_zip(&mut zip, &project_path, Path::new("app"), opts)?;
// Copy the determined source directory
add_dir_to_zip(&mut zip, &source_dir, Path::new("app"), opts)?;
// If we're using a subdirectory, also copy the root package.json with adjusted paths
if source_dir != project_path {
let root_package_json = project_path.join("package.json");
if root_package_json.exists() {
zip.start_file("app/package.json", opts)?;
// Read and modify package.json to adjust the main path
let content = fs::read_to_string(&root_package_json).context("Failed to read root package.json")?;
let mut package_value: Value = serde_json::from_str(&content).context("Failed to parse root package.json")?;
// Adjust the main field if it points to the source directory
if let Some(main) = package_value["main"].as_str() {
let main_path = project_path.join(main);
if let Ok(relative_to_source) = main_path.strip_prefix(&source_dir) {
package_value["main"] = Value::String(relative_to_source.to_string_lossy().to_string());
}
}
let modified_content = serde_json::to_string_pretty(&package_value)
.context("Failed to serialize modified package.json")?;
zip.write_all(modified_content.as_bytes())?;
}
}
// Copy Node runtime directory.
add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?;
zip.finish()?;
}
// 7. Build self-extracting launcher using a more reliable approach.
// 8. Build self-extracting launcher using a more reliable approach.
create_self_extracting_executable(&output_path, zip_data, &app_name)?;
println!("Bundle created at {}", output_path.display());
@@ -96,6 +122,150 @@ fn normalise_node_version(raw: &str) -> String {
raw.trim_start_matches('v').to_owned()
}
/// Determine the correct source directory to bundle for the project.
/// This handles TypeScript projects and other build configurations.
fn determine_source_directory(project_path: &Path, package_json: &Value) -> Result<PathBuf> {
// Check if there's a specific main entry point that indicates a built project
if let Some(main) = package_json["main"].as_str() {
let main_path = project_path.join(main);
if let Some(parent) = main_path.parent() {
// If main points to a file in a subdirectory like dist/index.js or build/index.js
let parent_name = parent.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if ["dist", "build", "lib", "out"].contains(&parent_name) && parent.exists() {
return Ok(parent.to_path_buf());
}
}
}
// Check for TypeScript configuration
let tsconfig_path = project_path.join("tsconfig.json");
if tsconfig_path.exists() {
if let Ok(tsconfig) = read_tsconfig(&tsconfig_path) {
if let Some(out_dir) = tsconfig["compilerOptions"]["outDir"].as_str() {
let out_path = project_path.join(out_dir);
if out_path.exists() {
return Ok(out_path);
}
}
}
}
// Check for common build output directories
for dir_name in ["dist", "build", "lib", "out"] {
let dir_path = project_path.join(dir_name);
if dir_path.exists() && dir_path.is_dir() {
// Verify it contains JavaScript files or a package.json
if contains_js_files(&dir_path) || dir_path.join("package.json").exists() {
return Ok(dir_path);
}
}
}
// Default to the project root
Ok(project_path.to_path_buf())
}
/// Read and parse tsconfig.json, handling extends configuration
fn read_tsconfig(tsconfig_path: &Path) -> Result<Value> {
let content = fs::read_to_string(tsconfig_path)
.context("Failed to read tsconfig.json")?;
// Remove comments for JSON parsing (simple approach)
let cleaned_content = content
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
let mut config: Value = serde_json::from_str(&cleaned_content)
.context("Failed to parse tsconfig.json")?;
// Handle extends configuration
if let Some(extends) = config["extends"].as_str() {
let base_path = if extends.starts_with('.') {
tsconfig_path.parent().unwrap().join(extends)
} else {
// Could be a package reference, but for now we'll skip
return Ok(config);
};
// Add .json extension if not present
let base_path = if base_path.extension().is_none() {
base_path.with_extension("json")
} else {
base_path
};
if base_path.exists() {
if let Ok(base_config) = read_tsconfig(&base_path) {
// Merge base config with current config (simple merge)
if let (Some(base_obj), Some(current_obj)) = (base_config.as_object(), config.as_object()) {
let mut merged = base_obj.clone();
for (key, value) in current_obj {
merged.insert(key.clone(), value.clone());
}
config = Value::Object(merged);
}
}
}
}
Ok(config)
}
/// Check if a directory contains JavaScript files
fn contains_js_files(dir: &Path) -> bool {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".js") || name.ends_with(".mjs") || name.ends_with(".cjs") {
return true;
}
}
}
}
false
}
/// Resolve the output path, handling naming conflicts
fn resolve_output_path(
output_path: Option<PathBuf>,
app_name: &str,
custom_name: Option<&str>,
) -> Result<PathBuf> {
if let Some(path) = output_path {
// If explicit output path is provided, use it as-is
return Ok(path);
}
let ext = if Platform::current().is_windows() { ".exe" } else { "" };
let base_name = custom_name.unwrap_or(app_name);
let mut output_path = PathBuf::from(format!("{base_name}{ext}"));
// Check for conflicts and resolve them
let mut counter = 1;
while output_path.exists() {
if output_path.is_dir() {
// If it's a directory, append a suffix
output_path = PathBuf::from(format!("{base_name}-bundle{ext}"));
if !output_path.exists() {
break;
}
}
// If it still exists (file or another directory), add a counter
if output_path.exists() {
output_path = PathBuf::from(format!("{base_name}-bundle-{counter}{ext}"));
counter += 1;
}
}
Ok(output_path)
}
// ────────────────────────────────────────────────────────────────────────────
// Self-extracting executable generation using a more reliable approach
// ────────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -1,4 +1,4 @@
mod bundler_simple;
mod bundler;
mod node_downloader;
mod platform;
@@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<()> {
match cli.command {
Commands::Bundle { path, output, name } => {
bundler_simple::bundle_project(path, output, name).await?;
bundler::bundle_project(path, output, name).await?;
}
}