Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/vite_error/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ pub enum Error {
#[error("{}", vite_shared::format_error_chain(.0))]
Reqwest(#[from] reqwest::Error),

// The shared HTTP client could not be built at all, so no request was
// attempted. Its own message already names the cause and the remedy.
#[error(transparent)]
HttpClient(#[from] vite_shared::HttpClientError),

#[error(transparent)]
JoinError(#[from] tokio::task::JoinError),

Expand Down
6 changes: 3 additions & 3 deletions crates/vite_install/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ impl HttpClient {
pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, Error> {
tracing::debug!("Fetching bytes from: {}", url);

let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;

// Read the body inside the retry so a mid-body connection drop gets
// retried instead of failing outright, like `download_file`.
Expand Down Expand Up @@ -116,7 +116,7 @@ impl HttpClient {
) -> Result<T, Error> {
tracing::debug!("Fetching JSON from: {} (accept: {:?})", url, accept);

let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;
(|| async {
let mut request = client.get(url);
if let Some(accept) = accept {
Expand Down Expand Up @@ -153,7 +153,7 @@ impl HttpClient {
let target_path = target_path.as_ref();
tracing::debug!("Downloading {} to {:?}", url, target_path);

let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;

// Make the request *and* the body stream a single retried unit. Doing
// the request inline (instead of calling `self.get`) avoids a double
Expand Down
6 changes: 3 additions & 3 deletions crates/vite_js_runtime/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub async fn download_file(
target_path: &AbsolutePath,
message: &str,
) -> Result<(), Error> {
let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;

tracing::debug!("Downloading {url} to {target_path:?}");

Expand Down Expand Up @@ -144,7 +144,7 @@ pub async fn download_file(
/// Download text content from a URL with retry logic
#[expect(clippy::disallowed_types, reason = "HTTP response body is a String")]
pub async fn download_text(url: &str) -> Result<String, Error> {
let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;

tracing::debug!("Downloading text from {url}");

Expand Down Expand Up @@ -173,7 +173,7 @@ pub async fn fetch_json_with_cache_headers<T: DeserializeOwned>(
url: &str,
if_none_match: Option<&str>,
) -> Result<CachedFetchResponse<T>, Error> {
let client = vite_shared::shared_http_client();
let client = vite_shared::shared_http_client()?;

tracing::debug!("Fetching with cache headers from {url}");

Expand Down
5 changes: 5 additions & 0 deletions crates/vite_js_runtime/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ pub enum Error {
#[error("{}", vite_shared::format_error_chain(.0))]
Reqwest(#[from] reqwest::Error),

/// The shared HTTP client could not be built at all, so no request was
/// attempted. Its own message already names the cause and the remedy.
#[error(transparent)]
HttpClient(#[from] vite_shared::HttpClientError),

/// Join error from tokio
#[error(transparent)]
JoinError(#[from] tokio::task::JoinError),
Expand Down
1 change: 1 addition & 0 deletions crates/vite_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ serde = { workspace = true }
# use `preserve_order` feature to preserve the order of the fields in `package.json`
serde_json = { workspace = true, features = ["preserve_order"] }
supports-color = "3"
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
vite_path = { workspace = true }
Expand Down
90 changes: 76 additions & 14 deletions crates/vite_shared/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,28 @@

use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration};

use vite_str::Str;

use crate::{env_vars, error::format_error_chain, output};

/// The process-wide HTTP client could not be built.
///
/// Building the client is where TLS and proxy configuration is first resolved,
/// so every failure here is an environment condition the user can fix: no
/// system CA bundle, an unusable proxy setting, an unparsable extra CA bundle.
/// `cause` carries the full `source()` chain, flattened by
/// [`crate::format_error_chain`] because `reqwest::Error` is not `Clone`.
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"could not initialize the HTTP client: {cause}\nCheck that a system CA bundle is installed \
(Debian/Ubuntu: `apt-get install -y ca-certificates`, Alpine: `apk add ca-certificates`) — \
minimal container images often ship none — and that HTTPS_PROXY / HTTP_PROXY and any bundle \
named by SSL_CERT_FILE or NODE_EXTRA_CA_CERTS are valid."
)]
pub struct HttpClientError {
cause: Str,
}

/// Per-request total timeout. Long enough for slow tarball downloads on
/// constrained CI runners, short enough that a single stuck stream doesn't
/// silently hang a build.
Expand All @@ -45,21 +65,15 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// The client is built on first call and reused thereafter. See module docs
/// for the env vars it honors.
///
/// Panics on the *first* call if reqwest fails to build the client (malformed
/// `HTTPS_PROXY`, unusable TLS backend, etc.); subsequent calls in the same
/// process panic with the same message. Panic — not `process::exit` — so
/// destructors of in-flight work still run (lockfiles released, tempfiles
/// cleaned) and an embedding Node host (NAPI) keeps the process alive.
#[must_use]
pub fn shared_http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
match CLIENT.get_or_init(build_client) {
Ok(client) => client,
Err(msg) => panic!("failed to initialize HTTP client: {msg}"),
}
/// Returns [`HttpClientError`] if the client cannot be built (no system CA
/// bundle, unusable `HTTPS_PROXY`, …). The outcome is cached, so later calls
/// report the same error without retrying.
pub fn shared_http_client() -> Result<&'static reqwest::Client, HttpClientError> {
static CLIENT: OnceLock<Result<reqwest::Client, HttpClientError>> = OnceLock::new();
CLIENT.get_or_init(build_client).as_ref().map_err(Clone::clone)
}

fn build_client() -> Result<reqwest::Client, String> {
fn build_client() -> Result<reqwest::Client, HttpClientError> {
crate::ensure_tls_provider();

let mut builder =
Expand Down Expand Up @@ -110,7 +124,7 @@ fn build_client() -> Result<reqwest::Client, String> {
builder = builder.tls_danger_accept_invalid_certs(true);
}

builder.build().map_err(|err| format_error_chain(&err))
builder.build().map_err(|err| HttpClientError { cause: format_error_chain(&err).into() })
}

/// Returns `true` only for clearly affirmative env-var values
Expand All @@ -136,6 +150,54 @@ mod tests {

use super::*;

/// Valid PEM, invalid DER: `from_pem_bundle` accepts it, `build` rejects
/// it. Portable way into the build-failure branch, unlike an empty trust
/// store.
const PEM_WITH_INVALID_DER: &[u8] =
b"-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----\n";

/// Callers already treat "no HTTP client" as a handled outcome, but a panic
/// never reaches them: `[profile.release]` sets `panic = "abort"`, so this
/// is a SIGABRT, not a recoverable unwind. Reported in the wild as
/// "No CA certificates were loaded from the system" in an image with no CA
/// bundle. Unwinds here only because the `test` profile unwinds.
#[test]
#[serial_test::serial(env)]
fn client_build_failure_reaches_the_caller_instead_of_panicking() {
let bundle = std::env::temp_dir()
.join(vite_str::format!("vp-invalid-ca-{}.pem", std::process::id()).as_str());
std::fs::write(&bundle, PEM_WITH_INVALID_DER).expect("write CA bundle fixture");
// SAFETY: env access in this module's tests is serialized.
unsafe {
std::env::set_var(env_vars::SSL_CERT_FILE, &bundle);
}

// Discard the value: only *how* the failure is reported is under test.
let outcome = std::panic::catch_unwind(|| {
let _ = shared_http_client();
});

unsafe {
std::env::remove_var(env_vars::SSL_CERT_FILE);
}
let _ = std::fs::remove_file(&bundle);

assert!(
outcome.is_ok(),
"building the shared HTTP client failed and panicked; a build failure has to be \
returned to the caller so it can be reported or handled"
);
}

#[test]
fn error_reports_the_cause_and_a_remedy() {
let cause = "No CA certificates were loaded from the system";
let message = HttpClientError { cause: cause.into() }.to_string();
assert!(message.contains(cause), "{message}");
assert!(message.contains("ca-certificates"), "{message}");
assert!(message.contains(env_vars::NODE_EXTRA_CA_CERTS), "{message}");
}

#[test]
fn os_str_is_blank_matches_whitespace_only() {
assert!(os_str_is_blank(&OsString::from("")));
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ mod tracing;
pub use env_config::{EnvConfig, TestEnvGuard};
pub use error::format_error_chain;
pub use home::{VP_BINARY_NAME, get_vp_home};
pub use http::shared_http_client;
pub use http::{HttpClientError, shared_http_client};
pub use json_edit::{JsonStyle, edit_json_object, insert_after};
pub use package_json::{
DevEngineDependency, DevEngineField, DevEngines, Engines, OnFail, PackageJson, dev_engine_entry,
Expand Down