mirror of
https://github.com/zhom/banderole.git
synced 2026-08-17 06:50:19 +02:00
refactor: improve performance
This commit is contained in:
Generated
+700
-760
File diff suppressed because it is too large
Load Diff
+33
-27
@@ -9,37 +9,39 @@ version = "0.2.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
lazy_static = "1.4"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
zip = "4"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
# rustls rather than the default native-tls: it drops the OpenSSL system dependency, so
|
||||
# `cargo install banderole` no longer needs libssl-dev / pkg-config on Linux.
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"stream",
|
||||
"json",
|
||||
"rustls",
|
||||
"webpki-roots",
|
||||
"charset",
|
||||
"http2",
|
||||
] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
uuid = { version = "1.24", features = ["v4"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
|
||||
directories = "6"
|
||||
anyhow = "1.0"
|
||||
walkdir = "2.4"
|
||||
futures-util = "0.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.20"
|
||||
base64 = "0.22"
|
||||
indicatif = "0.18"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
indicatif-log-bridge = "0.2"
|
||||
console = "0.16"
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
sevenz-rust = "0.6"
|
||||
anyhow = "1.0.104"
|
||||
walkdir = "2.5"
|
||||
futures-util = "0.3.33"
|
||||
tempfile = "3.27"
|
||||
indicatif = "0.18.6"
|
||||
log = "0.4.33"
|
||||
env_logger = "0.11.11"
|
||||
indicatif-log-bridge = "0.2.3"
|
||||
console = "0.16.4"
|
||||
tar = "0.4.46"
|
||||
# sevenz-rust is unmaintained; sevenz-rust2 is the maintained fork.
|
||||
sevenz-rust2 = "0.21"
|
||||
lzma-rs = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
serial_test = "4"
|
||||
|
||||
[[test]]
|
||||
name = "integration_test"
|
||||
@@ -61,6 +63,10 @@ harness = true
|
||||
name = "concurrent_execution_integration_test"
|
||||
harness = true
|
||||
|
||||
[[test]]
|
||||
name = "launcher_semantics_integration_test"
|
||||
harness = true
|
||||
|
||||
# Run tests sequentially to avoid resource conflicts
|
||||
[profile.test]
|
||||
opt-level = 0
|
||||
|
||||
@@ -4,11 +4,41 @@ Create cross-platform single-executables for Node.js projects. Windows is not su
|
||||
|
||||
Banderole bundles your Node.js app, all dependencies, and a portable Node binary into a single native executable. On first launch, it unpacks to a cache directory for fast subsequent executions.
|
||||
|
||||
Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches, but since it includes all dependencies by default, it has significantly larger filesize.
|
||||
Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches — it ships a stock Node binary and your real dependency tree rather than a patched runtime and a virtual filesystem.
|
||||
|
||||
## Performance
|
||||
|
||||
Measured on linux-x64, Node 22.17.1, against [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) 6.22.0 building the same app. Startup is the median of 60 interleaved runs (targets rotate every round, so machine drift affects both equally).
|
||||
|
||||
| | trivial app | | app with deps<br>(express, lodash, chalk, dayjs) | |
|
||||
|---|---|---|---|---|
|
||||
| | **banderole** | pkg | **banderole** | pkg |
|
||||
| Executable size | **33,416,408 B** | 74,306,703 B | **35,366,424 B** | 77,346,956 B |
|
||||
| Startup (median) | **16.1 ms** | 25.7 ms | **53.1 ms** | 78.8 ms |
|
||||
| Resident processes | **1** | 1 | **1** | 1 |
|
||||
| Resident memory (RSS) | **43.8 MiB** | 49.4 MiB | **66.4 MiB** | 76.2 MiB |
|
||||
|
||||
pkg's size floor is the uncompressed Node binary it embeds, which its `--compress` option does not touch. banderole compresses the Node binary with zstd and ships only the executable itself, so a bundle is roughly half the size.
|
||||
|
||||
The trade-off is the **first** launch, which unpacks the payload into the cache directory: 117 ms versus pkg's 30 ms for the trivial app. Every launch after that takes the warm path above.
|
||||
|
||||
### Scaling with project size
|
||||
|
||||
First-launch extraction grows with the dependency tree; steady-state launch does not, because it is one file read followed by `exec`.
|
||||
|
||||
| `node_modules` | files | bundle time | first launch | **every later launch** |
|
||||
|---|---|---|---|---|
|
||||
| — | 0 | 16 s | 159 ms | **19.4 ms** |
|
||||
| 14 MB | 5,500 | 18 s | 224 ms | **22.4 ms** |
|
||||
| 68 MB (real npm tree) | 9,725 | 19 s | 316 ms | **71.2 ms** |
|
||||
| 143 MB | 27,500 | 20 s | 348 ms | **23.3 ms** |
|
||||
| 763 MB | 110,000 | 25 s | 862 ms | **22.9 ms** |
|
||||
|
||||
A 763 MB dependency tree still unpacks in under a second, and warm launch is flat across the whole range. (The 68 MB row is a real npm install; its higher warm figure is Node resolving express/lodash/dayjs at runtime, not launcher overhead. The other rows are generated trees, which compress better than real ones.)
|
||||
|
||||
## Requirements
|
||||
|
||||
Banderole requires the Rust toolchain to be installed on your system to build portable executables.
|
||||
Banderole requires a Rust toolchain (`cargo`, `rustc`, `rustup`) **and a working C linker** — `cc`/`gcc`/`clang` on Unix, MSVC on Windows — because it compiles a small launcher for each bundle.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ allow = [
|
||||
"CC0-1.0",
|
||||
"MPL-2.0",
|
||||
"Zlib",
|
||||
"bzip2-1.0.6"
|
||||
"bzip2-1.0.6",
|
||||
# rustls stack (ring, untrusted, rustls-webpki) — replaces the OpenSSL/native-tls path.
|
||||
"ISC",
|
||||
# Mozilla root certificate bundle shipped by webpki-roots.
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
|
||||
confidence-threshold = 0.8
|
||||
|
||||
+135
-65
@@ -14,6 +14,27 @@ use std::time::Instant;
|
||||
|
||||
use zip::ZipWriter;
|
||||
|
||||
/// Zstd level for application and dependency files.
|
||||
///
|
||||
/// This is chosen for the *per-entry* cost, not for throughput. `ZipWriter` builds a fresh
|
||||
/// streaming encoder for every entry and cannot pledge the source size, so zstd commits to
|
||||
/// the level's full parameter set and allocates its match tables per file regardless of how
|
||||
/// small that file is. Measured cost of one encoder over a 108-byte input:
|
||||
///
|
||||
/// ```text
|
||||
/// level 3 → 148 us level 9 → 850 us level 12 → 30,021 us
|
||||
/// ```
|
||||
///
|
||||
/// That 35x cliff between 9 and 12 is fixed overhead, so it multiplies by file count: a
|
||||
/// 100,000-file dependency tree costs ~15 s at level 3 and ~50 minutes at level 12, for
|
||||
/// roughly 9% difference in size. Nothing above 9 is defensible for many-file payloads.
|
||||
const APP_COMPRESSION_LEVEL: i64 = 3;
|
||||
|
||||
/// Zstd level for the single Node executable. One entry pays the per-entry overhead once,
|
||||
/// and this is a fixed ~105 MB that dominates the output size, so compressing it hard is
|
||||
/// worth a few seconds.
|
||||
const NODE_COMPRESSION_LEVEL: i64 = 12;
|
||||
|
||||
/// Public entry-point used by `main.rs`.
|
||||
///
|
||||
/// * `project_path` – path that contains a `package.json`.
|
||||
@@ -27,7 +48,7 @@ pub async fn bundle_project(
|
||||
project_path: PathBuf,
|
||||
output_path: Option<PathBuf>,
|
||||
custom_name: Option<String>,
|
||||
_no_compression: bool,
|
||||
no_compression: bool,
|
||||
ignore_cached_versions: bool,
|
||||
multi: &MultiProgress,
|
||||
) -> Result<()> {
|
||||
@@ -133,10 +154,29 @@ pub async fn bundle_project(
|
||||
let mut zip_data: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data));
|
||||
// Always use uncompressed (Stored) for near-instant extraction
|
||||
// This makes the executable larger but launch time is much faster
|
||||
let opts: zip::write::FileOptions<'static, ()> =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
// Zstd is strictly better than Stored here: it cuts the payload several-fold and its
|
||||
// decompression is fast enough that the one-time first-run extraction still gets
|
||||
// *faster*, because there are far fewer bytes to write to disk. Warm launches never
|
||||
// touch the archive at all, so they are unaffected either way.
|
||||
//
|
||||
// The level differs by payload because the two halves scale differently. Measured on
|
||||
// real node_modules content, zstd throughput is 95.9 MB/s at level 3 but only
|
||||
// 17.6 MB/s at level 12 — and an application's dependency tree is unbounded, so a
|
||||
// high level there turns bundling a large project into minutes of compression. The
|
||||
// Node binary is a fixed ~105 MB that dominates the output size, so it is worth
|
||||
// compressing hard; a couple of seconds buys ~25% off the dominant term.
|
||||
let opts: zip::write::FileOptions<'static, ()> = if no_compression {
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored)
|
||||
} else {
|
||||
zip::write::FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Zstd)
|
||||
.compression_level(Some(APP_COMPRESSION_LEVEL))
|
||||
};
|
||||
let node_opts: zip::write::FileOptions<'static, ()> = if no_compression {
|
||||
opts
|
||||
} else {
|
||||
opts.compression_level(Some(NODE_COMPRESSION_LEVEL))
|
||||
};
|
||||
|
||||
// Pre-count app files
|
||||
let app_files = count_files_in_dir(&source_dir, true, true);
|
||||
@@ -159,17 +199,11 @@ pub async fn bundle_project(
|
||||
Some(&pb_bundle),
|
||||
)?;
|
||||
|
||||
// Count node runtime files and extend length
|
||||
let node_files = count_files_in_dir(node_root, false, true);
|
||||
let new_len = pb_bundle.length().unwrap_or(0) + node_files;
|
||||
// Only the Node executable itself is ever resolved at runtime, so the rest of the
|
||||
// distribution (headers, npm, corepack, docs, man pages) is dead weight.
|
||||
let new_len = pb_bundle.length().unwrap_or(0) + 1;
|
||||
pb_bundle.set_length(new_len);
|
||||
add_dir_to_zip(
|
||||
&mut zip,
|
||||
node_root,
|
||||
Path::new("node"),
|
||||
opts,
|
||||
Some(&pb_bundle),
|
||||
)?;
|
||||
add_node_runtime_to_zip(&mut zip, node_root, node_opts, Some(&pb_bundle))?;
|
||||
zip.finish()?;
|
||||
}
|
||||
pb_bundle.finish_and_clear();
|
||||
@@ -230,6 +264,92 @@ fn count_files_in_dir(dir: &Path, exclude_node_modules: bool, follow_links: bool
|
||||
count
|
||||
}
|
||||
|
||||
/// Add just the Node.js executable to the archive, at the path the launcher resolves.
|
||||
///
|
||||
/// The launcher only ever execs `node/bin/node` (or `node/node.exe`), so bundling the whole
|
||||
/// distribution ships ~65 MB of headers, npm and docs that nothing can reach. The binary is
|
||||
/// also stripped first where the platform allows it, which removes another ~14%.
|
||||
fn add_node_runtime_to_zip<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
node_root: &Path,
|
||||
opts: zip::write::FileOptions<'static, ()>,
|
||||
progress: Option<&ProgressBar>,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: Write + Read + std::io::Seek,
|
||||
{
|
||||
let (source_rel, archive_path) = if cfg!(windows) {
|
||||
(PathBuf::from("node.exe"), "node/node.exe")
|
||||
} else {
|
||||
(PathBuf::from("bin").join("node"), "node/bin/node")
|
||||
};
|
||||
|
||||
let node_binary = node_root.join(&source_rel);
|
||||
anyhow::ensure!(
|
||||
node_binary.exists(),
|
||||
"Node.js executable not found at {}",
|
||||
node_binary.display()
|
||||
);
|
||||
|
||||
let node_binary = strip_node_binary(&node_binary).unwrap_or(node_binary);
|
||||
|
||||
let opts = opts.unix_permissions(0o755).large_file(true);
|
||||
zip.start_file(archive_path, opts)?;
|
||||
|
||||
let mut file = fs::File::open(&node_binary)
|
||||
.with_context(|| format!("Failed to open {}", node_binary.display()))?;
|
||||
std::io::copy(&mut file, zip)
|
||||
.with_context(|| format!("Failed to add {} to archive", node_binary.display()))?;
|
||||
|
||||
if let Some(pb) = progress {
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Produce a stripped copy of the Node binary next to the cached distribution, reusing it on
|
||||
/// subsequent bundles. Returns `None` when stripping is unavailable or fails, in which case
|
||||
/// the caller falls back to the original binary.
|
||||
fn strip_node_binary(node_binary: &Path) -> Option<PathBuf> {
|
||||
// macOS ships signed binaries; stripping invalidates the signature and Gatekeeper then
|
||||
// kills the process, so leave those alone.
|
||||
if cfg!(target_os = "macos") || cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stripped = node_binary.with_extension("stripped");
|
||||
if let (Ok(orig), Ok(strip_meta)) = (node_binary.metadata(), stripped.metadata()) {
|
||||
// Reuse only if the stripped copy is newer than the original and non-empty.
|
||||
if strip_meta.len() > 0 {
|
||||
if let (Ok(a), Ok(b)) = (orig.modified(), strip_meta.modified()) {
|
||||
if b >= a {
|
||||
return Some(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::copy(node_binary, &stripped).ok()?;
|
||||
let status = std::process::Command::new("strip")
|
||||
.arg("--strip-all")
|
||||
.arg(&stripped)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
debug!("Stripped Node binary at {}", stripped.display());
|
||||
Some(stripped)
|
||||
}
|
||||
_ => {
|
||||
let _ = fs::remove_file(&stripped);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle dependencies with improved package manager support
|
||||
fn bundle_dependencies<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
@@ -1360,56 +1480,6 @@ fn resolve_output_path(
|
||||
// Utility helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn add_dir_to_zip<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
src_dir: &Path,
|
||||
dest_dir: &Path,
|
||||
opts: zip::write::FileOptions<'static, ()>,
|
||||
progress: Option<&ProgressBar>,
|
||||
) -> Result<()>
|
||||
where
|
||||
W: Write + Read + std::io::Seek,
|
||||
{
|
||||
for entry in walkdir::WalkDir::new(src_dir).follow_links(true) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let rel_path = path.strip_prefix(src_dir).unwrap();
|
||||
let zip_path = dest_dir.join(rel_path);
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_opts = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let metadata = fs::metadata(path)?;
|
||||
let permissions = metadata.permissions();
|
||||
let mode = permissions.mode();
|
||||
opts.unix_permissions(mode)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
opts
|
||||
}
|
||||
};
|
||||
|
||||
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
|
||||
let data = fs::read(path).context("Failed to read file while zipping")?;
|
||||
zip.write_all(&data)?;
|
||||
if let Some(pb) = progress {
|
||||
pb.inc(1);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add directory to zip without following symlinks but preserving them
|
||||
fn add_dir_to_zip_no_follow<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
|
||||
+39
-14
@@ -105,6 +105,10 @@ fn build_executable_with_progress(
|
||||
// Actual build; consume Cargo JSON messages to compute progress without a dry-run
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(build_dir)
|
||||
// A CARGO_TARGET_DIR inherited from the caller's environment would redirect the
|
||||
// launcher build somewhere else entirely, and we would then fail to find the binary
|
||||
// under build_dir/target.
|
||||
.env_remove("CARGO_TARGET_DIR")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
@@ -323,25 +327,45 @@ fn build_executable_with_progress(
|
||||
.ok()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default();
|
||||
let trim_tail = |mut s: String| {
|
||||
// Keep the *end* of a long log: that is where the failure is. (The previous version
|
||||
// discarded the result of `split_off`, which truncated the string in place and then
|
||||
// printed the middle of the log, never the error itself.)
|
||||
let trim_tail = |s: String| {
|
||||
const MAX: usize = 4000;
|
||||
if s.len() > MAX {
|
||||
s.split_off(s.len() - MAX)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if s.len() > MAX {
|
||||
s[s.len() - MAX..].to_string()
|
||||
} else {
|
||||
s
|
||||
match s.char_indices().nth_back(MAX.saturating_sub(1)) {
|
||||
Some((idx, _)) if s.len() > MAX => s[idx..].to_string(),
|
||||
_ => s,
|
||||
}
|
||||
};
|
||||
let out_tail = trim_tail(out);
|
||||
let err_tail = trim_tail(err);
|
||||
|
||||
// cargo emits diagnostics as JSON on stdout; surface the rendered compiler errors
|
||||
// directly instead of making the user read the raw message stream.
|
||||
let diagnostics: Vec<String> = out
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.filter(|v| v.get("reason").and_then(|r| r.as_str()) == Some("compiler-message"))
|
||||
.filter_map(|v| {
|
||||
let msg = v.get("message")?;
|
||||
if msg.get("level").and_then(|l| l.as_str()) != Some("error") {
|
||||
return None;
|
||||
}
|
||||
msg.get("rendered")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !diagnostics.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Cargo build failed.\nCompiler errors:\n{}",
|
||||
trim_tail(diagnostics.join("\n"))
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Cargo build failed.\nLast stdout:\n{}\nLast stderr:\n{}",
|
||||
out_tail,
|
||||
err_tail
|
||||
trim_tail(out),
|
||||
trim_tail(err)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,6 +417,7 @@ fn compute_total_via_cargo_metadata(build_dir: &Path, target_triple: &str) -> Re
|
||||
fn run_metadata(build_dir: &Path, args: &[&str]) -> Result<serde_json::Value> {
|
||||
let output = Command::new("cargo")
|
||||
.current_dir(build_dir)
|
||||
.env_remove("CARGO_TARGET_DIR")
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run cargo {}", args.join(" ")))?;
|
||||
|
||||
@@ -3,17 +3,15 @@ use crate::platform::Platform;
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::StreamExt;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use lazy_static::lazy_static;
|
||||
use log::info;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
lazy_static! {
|
||||
static ref NODE_VERSION_CACHE: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
static NODE_VERSION_CACHE: LazyLock<Mutex<HashMap<String, PathBuf>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub struct NodeDownloader {
|
||||
platform: Platform,
|
||||
@@ -295,7 +293,7 @@ impl NodeDownloader {
|
||||
if let Some(pb) = &progress {
|
||||
pb.set_message("Extracting 7z archive");
|
||||
}
|
||||
sevenz_rust::decompress_file(&archive_path, &target_dir)
|
||||
sevenz_rust2::decompress_file(&archive_path, &target_dir)
|
||||
.context("Failed to extract 7z archive")?;
|
||||
|
||||
// Post-process: many Node archives have a single top-level folder. Flatten it.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use anyhow::{Context, Result};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
lazy_static! {
|
||||
static ref VERSION_CACHE: Mutex<VersionCache> = Mutex::new(VersionCache::new());
|
||||
}
|
||||
static VERSION_CACHE: LazyLock<Mutex<VersionCache>> =
|
||||
LazyLock::new(|| Mutex::new(VersionCache::new()));
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeVersion {
|
||||
|
||||
+12
-10
@@ -6,19 +6,21 @@ edition = "2021"
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
directories = "6"
|
||||
zip = "4"
|
||||
# Only the codecs the bundler can actually emit. The default feature set drags in bzip2,
|
||||
# liblzma, aes, zopfli, deflate64 and ppmd-rust, none of which are ever used.
|
||||
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
|
||||
serde_json = "1.0"
|
||||
fs2 = "0.4"
|
||||
walkdir = "2.4"
|
||||
rayon = "1.10"
|
||||
# fs2 is unmaintained (last release 2018); fs4 is the maintained fork.
|
||||
fs4 = { version = "1.1", default-features = false, features = ["sync"] }
|
||||
|
||||
[build-dependencies]
|
||||
# No build dependencies needed - data is embedded at compile time
|
||||
|
||||
# Optimize for size and performance
|
||||
# The launcher's own code is a rounding error next to the embedded payload, so optimize the
|
||||
# extraction path for speed rather than for size.
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
lto = true # Enable Link Time Optimization
|
||||
codegen-units = 1 # Reduce number of codegen units to increase optimizations
|
||||
panic = "abort" # Abort on panic (smaller binary)
|
||||
strip = true # Strip symbols from binary
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
+299
-354
@@ -1,449 +1,394 @@
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use fs2::FileExt;
|
||||
use rayon::prelude::*;
|
||||
use fs4::FileExt;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use zip::ZipArchive;
|
||||
|
||||
// These will be replaced during the build process with actual embedded data
|
||||
// The build script will generate a data.rs file with the actual data
|
||||
include!(concat!(env!("OUT_DIR"), "/data.rs"));
|
||||
|
||||
/// Name of the marker file written after a successful extraction. Its contents are the
|
||||
/// resolved main script path, so the warm path never has to parse package.json.
|
||||
const READY_FILE: &str = ".ready";
|
||||
|
||||
/// Entries at or below this size are decompressed into a reusable buffer and written with a
|
||||
/// single `write_all`; larger ones stream through a buffered writer.
|
||||
const SMALL_ENTRY_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// One archive entry's destination, resolved during the planning pass so the parallel write
|
||||
/// pass never has to touch shared metadata.
|
||||
struct PlannedFile {
|
||||
index: usize,
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
#[cfg(unix)]
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
// args_os, not args: a bundled app must be able to receive non-UTF-8 arguments such as
|
||||
// a filename in an arbitrary encoding. `env::args()` panics on those.
|
||||
let args: Vec<std::ffi::OsString> = env::args_os().collect();
|
||||
|
||||
// Get cache directory
|
||||
let cache_dir = get_cache_dir().context("Failed to determine cache directory")?;
|
||||
let app_dir = cache_dir.join(&BUILD_ID);
|
||||
let ready_file = app_dir.join(".ready");
|
||||
let cache_dir = get_cache_dir_fast().context("Failed to determine cache directory")?;
|
||||
let app_dir = cache_dir.join(BUILD_ID);
|
||||
|
||||
// Check if already extracted and ready
|
||||
if ready_file.exists() && is_extraction_valid(&app_dir)? {
|
||||
return run_app(&app_dir, &args[1..]);
|
||||
// Warm path: a single read of the ready marker gives us everything we need. If anything
|
||||
// about the cache is stale or damaged, `run_app` returns and we fall through to a full
|
||||
// re-extraction below, so we do not need to stat the payload up front.
|
||||
if let Ok(main_script) = fs::read_to_string(app_dir.join(READY_FILE)) {
|
||||
let main_script = main_script.trim();
|
||||
if !main_script.is_empty() {
|
||||
run_app(&app_dir, main_script, &args[1..])?;
|
||||
}
|
||||
}
|
||||
|
||||
// Use file locking to prevent concurrent extraction
|
||||
let lock_file_path = cache_dir.join(format!("{}.lock", BUILD_ID));
|
||||
// Cold path: extract under an exclusive lock so concurrent launches cooperate.
|
||||
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
|
||||
let lock_file_path = cache_dir.join(format!("{BUILD_ID}.lock"));
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_file_path)
|
||||
.with_context(|| format!("Failed to create lock file at {}", lock_file_path.display()))?;
|
||||
|
||||
// Acquire exclusive lock
|
||||
lock_file
|
||||
.lock_exclusive()
|
||||
.context("Failed to acquire extraction lock")?;
|
||||
FileExt::lock(&lock_file).context("Failed to acquire extraction lock")?;
|
||||
|
||||
// Double-check if extraction completed while waiting for lock
|
||||
if ready_file.exists() && is_extraction_valid(&app_dir)? {
|
||||
// Release lock and run
|
||||
lock_file.unlock().ok();
|
||||
return run_app(&app_dir, &args[1..]);
|
||||
// Another process may have completed the extraction while we waited for the lock. Keep
|
||||
// holding the lock across this attempt: a successful exec drops it automatically (the fd
|
||||
// is close-on-exec), and if the cache turns out to be unusable we still own the right to
|
||||
// re-extract below.
|
||||
let ready_path = app_dir.join(READY_FILE);
|
||||
if let Ok(main_script) = fs::read_to_string(&ready_path) {
|
||||
let main_script = main_script.trim().to_string();
|
||||
if !main_script.is_empty() {
|
||||
run_app(&app_dir, &main_script, &args[1..])?;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract application if needed
|
||||
extract_application(&app_dir)
|
||||
.with_context(|| format!("Failed to extract application to {}", app_dir.display()))?;
|
||||
|
||||
// Mark as ready
|
||||
fs::write(&ready_file, "ready")
|
||||
.with_context(|| format!("Failed to create ready file at {}", ready_file.display()))?;
|
||||
let main_script = find_main_script(&app_dir.join("app"))?;
|
||||
|
||||
// Release lock
|
||||
lock_file
|
||||
.unlock()
|
||||
// The ready marker doubles as the cache of the resolved main script. Write it last so a
|
||||
// partially extracted directory is never mistaken for a usable one.
|
||||
fs::write(&ready_path, &main_script)
|
||||
.with_context(|| format!("Failed to create ready file at {}", ready_path.display()))?;
|
||||
|
||||
FileExt::unlock(&lock_file)
|
||||
.context("Failed to release extraction lock")?;
|
||||
|
||||
// Run the application
|
||||
run_app(&app_dir, &args[1..])
|
||||
run_app(&app_dir, &main_script, &args[1..])?;
|
||||
|
||||
// `run_app` only returns when it could not start Node at all.
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to execute Node.js application from {}",
|
||||
app_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf> {
|
||||
let cache_dir = BaseDirs::new().unwrap().cache_dir().join("banderole");
|
||||
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
|
||||
Ok(cache_dir)
|
||||
fn get_cache_dir_fast() -> Result<PathBuf> {
|
||||
// Deliberately does not create the directory: the warm path never needs it to exist, and
|
||||
// create_dir_all costs a syscall on every launch.
|
||||
let base = BaseDirs::new().context("Failed to determine base directories")?;
|
||||
Ok(base.cache_dir().join("banderole"))
|
||||
}
|
||||
|
||||
fn get_node_executable_path(app_dir: &Path) -> PathBuf {
|
||||
let node_dir = app_dir.join("node");
|
||||
if cfg!(windows) {
|
||||
// Prefer common locations first
|
||||
let candidates = [node_dir.join("node.exe")];
|
||||
for c in candidates {
|
||||
if c.exists() {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively search for node.exe under node/
|
||||
if node_dir.exists() {
|
||||
for entry in walkdir::WalkDir::new(&node_dir).follow_links(true) {
|
||||
if let Ok(e) = entry {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
|
||||
if name.eq_ignore_ascii_case("node.exe") {
|
||||
return p.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: default where Windows Node is usually at after extraction
|
||||
node_dir.join("node.exe")
|
||||
} else {
|
||||
// On Unix systems, Node.js is in node/bin/node
|
||||
let candidate = node_dir.join("bin").join("node");
|
||||
if candidate.exists() {
|
||||
candidate
|
||||
} else {
|
||||
// As a last resort, search recursively
|
||||
if node_dir.exists() {
|
||||
for entry in walkdir::WalkDir::new(&node_dir).follow_links(true) {
|
||||
if let Ok(e) = entry {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
|
||||
if name == "node" {
|
||||
return p.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidate
|
||||
}
|
||||
node_dir.join("bin").join("node")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_extraction_valid(app_dir: &Path) -> Result<bool> {
|
||||
let app_package_json = app_dir.join("app").join("package.json");
|
||||
let node_executable = get_node_executable_path(app_dir);
|
||||
#[cfg(windows)]
|
||||
let node_executable = node_executable
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| node_executable.clone());
|
||||
|
||||
let package_exists = app_package_json.exists();
|
||||
let node_exists = node_executable.exists();
|
||||
|
||||
if !package_exists || !node_exists {
|
||||
// Log debugging information for failed validation
|
||||
eprintln!("Extraction validation failed:");
|
||||
eprintln!(" App directory: {}", app_dir.display());
|
||||
eprintln!(
|
||||
" Package.json exists: {} ({})",
|
||||
package_exists,
|
||||
app_package_json.display()
|
||||
);
|
||||
eprintln!(
|
||||
" Node executable exists: {} ({})",
|
||||
node_exists,
|
||||
node_executable.display()
|
||||
);
|
||||
|
||||
if let Ok(entries) = fs::read_dir(app_dir) {
|
||||
eprintln!(" App directory contents:");
|
||||
for entry in entries.flatten() {
|
||||
eprintln!(" - {}", entry.file_name().to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(app_dir.join("node")) {
|
||||
eprintln!(" Node directory contents:");
|
||||
for entry in entries.flatten() {
|
||||
eprintln!(" - {}", entry.file_name().to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(package_exists && node_exists)
|
||||
}
|
||||
|
||||
struct FileEntry {
|
||||
path: PathBuf,
|
||||
data: Vec<u8>,
|
||||
#[cfg(unix)]
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
struct DirEntry {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn extract_application(app_dir: &Path) -> Result<()> {
|
||||
// Remove existing directory if it exists to ensure clean extraction
|
||||
if app_dir.exists() {
|
||||
fs::remove_dir_all(app_dir).context("Failed to remove existing app directory")?;
|
||||
}
|
||||
|
||||
// Create app directory
|
||||
fs::create_dir_all(app_dir).context("Failed to create app directory")?;
|
||||
|
||||
// Read ZIP data directly from embedded bytes (no XZ decompression needed)
|
||||
let cursor = Cursor::new(ZIP_DATA);
|
||||
let mut archive = ZipArchive::new(cursor).context("Failed to open embedded zip archive")?;
|
||||
|
||||
// First pass: collect all entries from the ZIP archive
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
||||
|
||||
// Get the file name from the zip entry (clone to owned String to avoid borrow issues)
|
||||
let file_name = file.name().to_string();
|
||||
|
||||
// Skip entries with invalid characters or paths
|
||||
if file_name.is_empty() || file_name.contains('\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine if this is a directory entry
|
||||
let is_directory = file_name.ends_with('/') || file.is_dir();
|
||||
|
||||
// Skip empty directory entries that are just the trailing slash
|
||||
if is_directory && (file_name == "/" || file_name.trim_matches('/').is_empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove trailing slash for proper path construction
|
||||
let clean_file_name = if is_directory {
|
||||
file_name.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
file_name.clone()
|
||||
};
|
||||
|
||||
// Skip if the cleaned name is empty (shouldn't happen but be safe)
|
||||
if clean_file_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use proper path handling instead of string replacement
|
||||
// Split the path by forward slashes and join using PathBuf for proper platform handling
|
||||
let path_components: Vec<&str> = clean_file_name
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// Skip if no valid path components
|
||||
if path_components.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut outpath = app_dir.to_path_buf();
|
||||
for component in path_components {
|
||||
outpath = outpath.join(component);
|
||||
}
|
||||
|
||||
// Ensure the path is within the app directory (security check)
|
||||
if !outpath.starts_with(app_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_directory {
|
||||
dirs.push(DirEntry { path: outpath });
|
||||
} else {
|
||||
// Read file data into memory
|
||||
let mut data = Vec::new();
|
||||
std::io::copy(&mut file, &mut data)
|
||||
.with_context(|| format!("Failed to read file data from {}", file_name))?;
|
||||
// Parsed exactly once. `ZipArchive` holds its central directory behind an `Arc`, so the
|
||||
// per-worker clones below are O(1) — re-opening the archive per worker instead would
|
||||
// re-parse every entry per thread, which is the difference between 110k and 1.7M entry
|
||||
// parses on a large project.
|
||||
let archive =
|
||||
ZipArchive::new(Cursor::new(ZIP_DATA)).context("Failed to open embedded zip archive")?;
|
||||
let len = archive.len();
|
||||
|
||||
// Resolve every entry's destination path up front (metadata only), so the write pass can
|
||||
// run in parallel without touching the shared central directory.
|
||||
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
|
||||
let mut files: Vec<PlannedFile> = Vec::with_capacity(len);
|
||||
{
|
||||
let mut archive = archive.clone();
|
||||
// Entries arrive in directory-walk order, so consecutive files nearly always share a
|
||||
// parent. Remembering the last one turns ~100k set insertions into ~10k.
|
||||
let mut last_parent: Option<PathBuf> = None;
|
||||
for index in 0..len {
|
||||
let entry = archive
|
||||
.by_index_raw(index)
|
||||
.context("Failed to read zip entry")?;
|
||||
let is_dir = entry.is_dir() || entry.name().ends_with('/');
|
||||
let Some(path) = safe_join(app_dir, entry.name()) else {
|
||||
continue;
|
||||
};
|
||||
let size = entry.size();
|
||||
#[cfg(unix)]
|
||||
let mode = file.unix_mode();
|
||||
let mode = entry.unix_mode();
|
||||
drop(entry);
|
||||
|
||||
files.push(FileEntry {
|
||||
path: outpath,
|
||||
data,
|
||||
if is_dir {
|
||||
dirs.insert(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
// Archives do not reliably carry directory entries, so every file's parent is
|
||||
// recorded too.
|
||||
if last_parent.as_deref() != Some(parent) {
|
||||
dirs.insert(parent.to_path_buf());
|
||||
last_parent = Some(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
files.push(PlannedFile {
|
||||
index,
|
||||
path,
|
||||
size,
|
||||
#[cfg(unix)]
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create all directories (must be sequential to avoid conflicts)
|
||||
for dir in dirs {
|
||||
fs::create_dir_all(&dir.path)
|
||||
.with_context(|| format!("Failed to create directory '{}'", dir.path.display()))?;
|
||||
// Sorted order means a parent is always created before its children, and `create_dir_all`
|
||||
// then short-circuits on the already-existing prefix.
|
||||
for dir in &dirs {
|
||||
fs::create_dir_all(dir)
|
||||
.with_context(|| format!("Failed to create directory '{}'", dir.display()))?;
|
||||
}
|
||||
|
||||
// Third pass: write all files in parallel for maximum speed
|
||||
files.par_iter().try_for_each(|entry| -> Result<()> {
|
||||
// Create parent directories first
|
||||
if let Some(parent) = entry.path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directory '{}' for file '{}'",
|
||||
parent.display(),
|
||||
entry.path.display()
|
||||
)
|
||||
})?;
|
||||
// Largest first. The bundler appends the ~105 MB Node binary as the *last* entry, so any
|
||||
// static split of the work list hands it to one worker that is still writing long after
|
||||
// the rest have finished. Longest-job-first plus a shared cursor keeps every core busy to
|
||||
// the end.
|
||||
files.sort_unstable_by(|a, b| b.size.cmp(&a.size));
|
||||
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(files.len().max(1));
|
||||
let next = AtomicUsize::new(0);
|
||||
|
||||
let result: Result<()> = std::thread::scope(|scope| {
|
||||
let mut handles = Vec::with_capacity(threads);
|
||||
for _ in 0..threads {
|
||||
let mut archive = archive.clone();
|
||||
let files = &files;
|
||||
let next = &next;
|
||||
handles.push(scope.spawn(move || -> Result<()> {
|
||||
// Reused across entries so the common small-file case is one allocation per
|
||||
// worker rather than one per file.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(planned) = files.get(i) else { break };
|
||||
let path = &planned.path;
|
||||
|
||||
let mut entry = archive
|
||||
.by_index(planned.index)
|
||||
.context("Failed to read zip entry")?;
|
||||
let mut out = fs::File::create(path)
|
||||
.with_context(|| format!("Failed to create file {}", path.display()))?;
|
||||
|
||||
if planned.size <= SMALL_ENTRY_BYTES {
|
||||
buf.clear();
|
||||
buf.reserve(planned.size as usize);
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
out.write_all(&buf).with_context(|| {
|
||||
format!("Failed to write file to {}", path.display())
|
||||
})?;
|
||||
} else {
|
||||
// A big entry through io::copy's 8 KiB loop is tens of thousands of
|
||||
// write syscalls; buffer it instead.
|
||||
let mut writer = std::io::BufWriter::with_capacity(1 << 20, &mut out);
|
||||
std::io::copy(&mut entry, &mut writer).with_context(|| {
|
||||
format!("Failed to write file to {}", path.display())
|
||||
})?;
|
||||
writer.flush().with_context(|| {
|
||||
format!("Failed to flush file {}", path.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(mode) = planned.mode {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// fchmod on the open descriptor: no second path resolution, and
|
||||
// identical semantics to the previous path-based set_permissions.
|
||||
out.set_permissions(std::fs::Permissions::from_mode(mode))
|
||||
.with_context(|| {
|
||||
format!("Failed to set permissions on {}", path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
// Write file
|
||||
fs::write(&entry.path, &entry.data)
|
||||
.with_context(|| format!("Failed to write file to {}", entry.path.display()))?;
|
||||
|
||||
// Set executable permissions on Unix systems
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(mode) = entry.mode {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let permissions = std::fs::Permissions::from_mode(mode);
|
||||
fs::set_permissions(&entry.path, permissions).with_context(|| {
|
||||
format!("Failed to set permissions on {}", entry.path.display())
|
||||
})?;
|
||||
}
|
||||
for handle in handles {
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| anyhow::anyhow!("extraction worker panicked"))??;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
});
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app(app_dir: &Path, args: &[String]) -> Result<()> {
|
||||
/// Join a zip entry name onto `root`, rejecting absolute paths and traversal escapes.
|
||||
///
|
||||
/// Both `/` and `\` are treated as separators regardless of host platform. Zip names are
|
||||
/// specified to use `/`, but `PathBuf::push` also splits on `\` under Windows — so accepting
|
||||
/// a backslash as an ordinary character here would let an entry named `a\..\..\evil` escape
|
||||
/// `root` on Windows while passing a component-wise `starts_with` check.
|
||||
fn safe_join(root: &Path, name: &str) -> Option<PathBuf> {
|
||||
if name.is_empty() || name.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = root.to_path_buf();
|
||||
let mut pushed = false;
|
||||
for component in name
|
||||
.split(['/', '\\'])
|
||||
.filter(|s| !s.is_empty() && *s != ".")
|
||||
{
|
||||
if component == ".." {
|
||||
return None;
|
||||
}
|
||||
// Reject anything Windows would reinterpret: drive-relative prefixes, and trailing
|
||||
// dots or spaces which the Win32 layer silently strips.
|
||||
if component.contains(':') || component.ends_with('.') || component.ends_with(' ') {
|
||||
return None;
|
||||
}
|
||||
out.push(component);
|
||||
pushed = true;
|
||||
}
|
||||
|
||||
if !pushed || !out.starts_with(root) {
|
||||
return None;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Start the bundled Node.js app. On Unix this *replaces* the current process, so it only
|
||||
/// returns if Node could not be started at all.
|
||||
fn run_app(app_dir: &Path, main_script: &str, args: &[std::ffi::OsString]) -> Result<()> {
|
||||
let app_path = app_dir.join("app");
|
||||
let node_executable = get_node_executable_path(app_dir);
|
||||
|
||||
// Verify Node.js executable exists and is accessible
|
||||
if !node_executable.exists() {
|
||||
let app_dir_contents = fs::read_dir(&app_dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|e| vec![format!("Error reading app dir: {}", e)]);
|
||||
// Returning from here leaves the caller free to wipe and re-extract `app_dir`, so the
|
||||
// process must not be left with its cwd inside that directory.
|
||||
let previous_cwd = env::current_dir().ok();
|
||||
let restore_cwd = || {
|
||||
if let Some(cwd) = previous_cwd.as_ref() {
|
||||
let _ = env::set_current_dir(cwd);
|
||||
}
|
||||
};
|
||||
|
||||
let node_dir_contents = fs::read_dir(app_dir.join("node"))
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|e| vec![format!("Error reading node dir: {}", e)]);
|
||||
if env::set_current_dir(&app_path).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node.js executable not found at: {}\nPlatform: {} {}\nApp directory contents: {:?}\nNode directory contents: {:?}",
|
||||
node_executable.display(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
app_dir_contents,
|
||||
node_dir_contents
|
||||
));
|
||||
let mut command = Command::new(&node_executable);
|
||||
command.arg(main_script).args(args);
|
||||
|
||||
// Persist V8's compiled bytecode next to the extracted app so it survives between runs.
|
||||
// This is what closes the gap with bundlers that ship precompiled bytecode: without it
|
||||
// every launch recompiles the app's JavaScript, and the cost grows with dependency
|
||||
// count. Node < 22.1 simply ignores the variable, and a caller that sets it explicitly
|
||||
// keeps their own choice.
|
||||
if env::var_os("NODE_COMPILE_CACHE").is_none() {
|
||||
command.env("NODE_COMPILE_CACHE", app_dir.join(".v8cache"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
// Replaces this process image with Node: no fork, no resident parent holding the
|
||||
// launcher's address space for the lifetime of the app, and signals/exit status are
|
||||
// delivered by the kernel directly to Node.
|
||||
let err = command.exec();
|
||||
// exec() only returns on failure.
|
||||
restore_cwd();
|
||||
if node_executable.exists() {
|
||||
return Err(anyhow::anyhow!(err).context(format!(
|
||||
"Failed to execute Node.js at {}",
|
||||
node_executable.display()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// On Windows, verify the executable is actually executable
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(metadata) = fs::metadata(&node_executable) {
|
||||
if !metadata.is_file() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node.js executable path exists but is not a file: {}",
|
||||
use std::process::Stdio;
|
||||
let mut last_err: Option<std::io::Error> = None;
|
||||
for attempt in 1..=8u32 {
|
||||
match command
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status()
|
||||
{
|
||||
Ok(status) => std::process::exit(status.code().unwrap_or(1)),
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50 * attempt as u64));
|
||||
}
|
||||
}
|
||||
}
|
||||
restore_cwd();
|
||||
if node_executable.exists() {
|
||||
if let Some(e) = last_err {
|
||||
return Err(anyhow::anyhow!(e).context(format!(
|
||||
"Failed to execute Node.js at {}",
|
||||
node_executable.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot read metadata for Node.js executable: {}",
|
||||
node_executable.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app directory exists
|
||||
if !app_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"App directory not found at: {}",
|
||||
app_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Change to app directory
|
||||
env::set_current_dir(&app_path)
|
||||
.with_context(|| format!("Failed to change to app directory: {}", app_path.display()))?;
|
||||
|
||||
// Find main script from package.json
|
||||
let main_script = find_main_script(&app_path)?;
|
||||
|
||||
// Build command arguments
|
||||
let mut cmd_args = vec![main_script.clone()];
|
||||
cmd_args.extend(args.iter().cloned());
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let max_attempts: u32 = 8;
|
||||
let mut status: Option<std::process::ExitStatus> = None;
|
||||
for attempt in 1..=max_attempts {
|
||||
let status_res = Command::new(&node_executable)
|
||||
.args(&cmd_args)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status();
|
||||
match status_res {
|
||||
Ok(s) => {
|
||||
status = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(e).context(format!(
|
||||
"Failed to execute Node.js application (attempt {attempt}/{max_attempts})\nExecutable: {}\nMain script: {}\nArgs: {:?}\nWorking directory: {}",
|
||||
node_executable.display(),
|
||||
main_script,
|
||||
cmd_args,
|
||||
app_path.display()
|
||||
)));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::time::Duration;
|
||||
std::thread::sleep(Duration::from_millis(50 * attempt as u64));
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if attempt >= 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let status = status.ok_or_else(|| {
|
||||
last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to execute Node.js application after {} attempts",
|
||||
max_attempts
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
fn find_main_script(app_path: &Path) -> Result<String> {
|
||||
let package_json_path = app_path.join("package.json");
|
||||
|
||||
if package_json_path.exists() {
|
||||
let package_content =
|
||||
fs::read_to_string(&package_json_path).context("Failed to read package.json")?;
|
||||
|
||||
if let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&package_content) {
|
||||
if let Ok(content) = fs::read_to_string(&package_json_path) {
|
||||
if let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(main) = package_json["main"].as_str() {
|
||||
return Ok(main.to_string());
|
||||
if !main.trim().is_empty() {
|
||||
return Ok(main.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to index.js
|
||||
Ok("index.js".to_string())
|
||||
}
|
||||
|
||||
+17
-17
@@ -816,7 +816,7 @@ impl BundlerTestHelper {
|
||||
exec_to_run.display(),
|
||||
args,
|
||||
env_vars,
|
||||
&work_dir
|
||||
work_dir
|
||||
)
|
||||
})?;
|
||||
Ok(output)
|
||||
@@ -862,24 +862,24 @@ impl BundlerTestHelper {
|
||||
pub struct TestCacheManager;
|
||||
|
||||
impl TestCacheManager {
|
||||
/// The directory the launcher extracts bundles into, matching `directories::BaseDirs`.
|
||||
pub fn application_cache_dir() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
std::env::var_os("LOCALAPPDATA")
|
||||
.map(|d| PathBuf::from(d).join("banderole"))
|
||||
.unwrap_or_else(|| PathBuf::from("banderole-cache"))
|
||||
} else if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") {
|
||||
PathBuf::from(xdg_cache).join("banderole")
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
PathBuf::from(home).join(".cache").join("banderole")
|
||||
} else {
|
||||
PathBuf::from("/tmp").join("banderole-cache")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear application cache for testing
|
||||
pub fn clear_application_cache() -> Result<()> {
|
||||
// Determine cache directory based on platform
|
||||
let cache_dir = if cfg!(windows) {
|
||||
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
|
||||
std::path::PathBuf::from(local_app_data).join("banderole")
|
||||
} else {
|
||||
return Ok(()); // Can't determine cache dir, skip cleanup
|
||||
}
|
||||
} else if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") {
|
||||
std::path::PathBuf::from(xdg_cache).join("banderole")
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
std::path::PathBuf::from(home)
|
||||
.join(".cache")
|
||||
.join("banderole")
|
||||
} else {
|
||||
std::path::PathBuf::from("/tmp").join("banderole-cache")
|
||||
};
|
||||
let cache_dir = Self::application_cache_dir();
|
||||
|
||||
if cache_dir.exists() {
|
||||
println!("Clearing application cache at: {}", cache_dir.display());
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Covers the launcher behaviour introduced when the runtime switched from
|
||||
//! "spawn Node as a child and wait" to "exec() into Node", and when the payload
|
||||
//! was reduced to just the Node executable.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{BundlerTestHelper, TestCacheManager};
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Writes a project whose entrypoint can exit with a chosen code or idle forever.
|
||||
fn write_probe_project(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let app = root.join("probe-app");
|
||||
fs::create_dir_all(&app)?;
|
||||
|
||||
fs::write(
|
||||
app.join("package.json"),
|
||||
r#"{
|
||||
"name": "probe-app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
fs::write(
|
||||
app.join("index.js"),
|
||||
r#"
|
||||
const mode = process.argv[2] || "hello";
|
||||
if (mode === "exit") {
|
||||
process.exit(Number(process.argv[3] || 0));
|
||||
} else if (mode === "idle") {
|
||||
console.log("ready");
|
||||
setInterval(() => {}, 1000);
|
||||
} else if (mode === "args") {
|
||||
console.log(JSON.stringify(process.argv.slice(2)));
|
||||
} else {
|
||||
console.log("probe ok");
|
||||
}
|
||||
"#,
|
||||
)?;
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// The launcher must hand the process over to Node rather than supervising it, so exit
|
||||
/// codes come straight from the app and no launcher process stays resident.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn test_launcher_execs_into_node() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = write_probe_project(temp_dir.path())?;
|
||||
let out_dir = temp_dir.path().join("out");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
TestCacheManager::clear_application_cache()?;
|
||||
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
|
||||
|
||||
// First launch performs the extraction; second launch takes the warm path. Both must
|
||||
// behave identically.
|
||||
for pass in ["cold", "warm"] {
|
||||
let out = BundlerTestHelper::run_executable(&exe, &[], &[])?;
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{pass} launch failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("probe ok"),
|
||||
"{pass} launch produced unexpected stdout: {}",
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
// Arbitrary non-zero exit codes must survive. Under the old spawn+wait launcher this
|
||||
// went through `status.code().unwrap_or(1)`; under exec() the kernel reports it directly.
|
||||
for code in [0, 3, 42] {
|
||||
let out = BundlerTestHelper::run_executable(&exe, &["exit", &code.to_string()], &[])?;
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(code),
|
||||
"exit code {code} was not propagated"
|
||||
);
|
||||
}
|
||||
|
||||
// Arguments after the executable name must reach the script untouched.
|
||||
let out = BundlerTestHelper::run_executable(&exe, &["args", "--flag", "value"], &[])?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("\"--flag\"") && stdout.contains("\"value\""),
|
||||
"arguments were not forwarded: {stdout}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The launcher process must be *replaced* by Node, not remain as its parent: a resident
|
||||
/// parent doubles the process count and the memory held while the app is idle or sleeping.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn test_no_launcher_process_remains_resident() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = write_probe_project(temp_dir.path())?;
|
||||
let out_dir = temp_dir.path().join("out");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
TestCacheManager::clear_application_cache()?;
|
||||
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
|
||||
|
||||
// Warm the cache so the timing below is not racing the first-run extraction.
|
||||
BundlerTestHelper::run_executable(&exe, &[], &[])?;
|
||||
|
||||
let mut child = Command::new(&exe)
|
||||
.arg("idle")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
// Wait for the app to announce itself so we know exec() has already happened.
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut buf = [0u8; 32];
|
||||
let n = stdout.read(&mut buf)?;
|
||||
assert!(
|
||||
String::from_utf8_lossy(&buf[..n]).contains("ready"),
|
||||
"idle app never became ready"
|
||||
);
|
||||
|
||||
// Sample /proc *before* asserting anything, then always reap the child, so a failed
|
||||
// assertion cannot leave an idle Node process behind.
|
||||
let pid = child.id();
|
||||
let children =
|
||||
fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).unwrap_or_default();
|
||||
let comm = fs::read_to_string(format!("/proc/{pid}/comm")).ok();
|
||||
|
||||
child.kill().ok();
|
||||
child.wait().ok();
|
||||
|
||||
// The spawned pid itself is now Node, so it must have no children of its own.
|
||||
assert!(
|
||||
children.trim().is_empty(),
|
||||
"launcher still has child processes ({children:?}); it did not exec into Node"
|
||||
);
|
||||
if let Some(comm) = comm {
|
||||
assert_eq!(
|
||||
comm.trim(),
|
||||
"node",
|
||||
"spawned process should have been replaced by Node"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Only the Node executable belongs in the payload. Shipping the whole distribution added
|
||||
/// ~65 MB of headers, npm and docs that the launcher can never reach.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn test_payload_contains_only_node_executable() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = write_probe_project(temp_dir.path())?;
|
||||
let out_dir = temp_dir.path().join("out");
|
||||
fs::create_dir_all(&out_dir)?;
|
||||
|
||||
TestCacheManager::clear_application_cache()?;
|
||||
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
|
||||
|
||||
// A full Node distribution is ~186 MB uncompressed; the compressed single binary is
|
||||
// well under a third of that. The bound is deliberately loose so ordinary Node version
|
||||
// drift does not make this flaky.
|
||||
let size = fs::metadata(&exe)?.len();
|
||||
assert!(
|
||||
size < 80 * 1024 * 1024,
|
||||
"bundle is {size} bytes; expected well under 80 MB, so the payload is likely \
|
||||
carrying the whole Node distribution again"
|
||||
);
|
||||
|
||||
// Run it so the cache directory is populated, then inspect what actually landed there.
|
||||
let out = BundlerTestHelper::run_executable(&exe, &[], &[])?;
|
||||
assert!(out.status.success());
|
||||
|
||||
let cache_root = TestCacheManager::application_cache_dir();
|
||||
let app_dir = fs::read_dir(&cache_root)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.find(|p| p.is_dir() && p.join("node").exists())
|
||||
.ok_or("no extracted bundle found in the cache directory")?;
|
||||
|
||||
let node_dir = app_dir.join("node");
|
||||
let node_binary = if cfg!(windows) {
|
||||
node_dir.join("node.exe")
|
||||
} else {
|
||||
node_dir.join("bin").join("node")
|
||||
};
|
||||
assert!(
|
||||
node_binary.exists(),
|
||||
"extracted bundle is missing the Node executable at {}",
|
||||
node_binary.display()
|
||||
);
|
||||
|
||||
for unwanted in ["include", "share"] {
|
||||
assert!(
|
||||
!node_dir.join(unwanted).exists(),
|
||||
"extracted bundle still ships node/{unwanted}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!node_dir.join("lib").join("node_modules").exists(),
|
||||
"extracted bundle still ships npm/corepack under node/lib/node_modules"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user