use std::error::Error as StdError;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use primitives_rs::tasks::TaskRuntime;
use crate::build_output::{BuildOutput, BuildSnapshot, RuntimeMode, TestBuild};
use crate::contract::{
ContractError, DeclarationDescription, describe, write_contract_if_requested,
};
use crate::declarations::{ApiEndpoint, TaskMiddleware, ViewLoader};
use crate::dev::{CHECK_BUILD_DIR_ENV_VAR, is_dev, read_vercel_deployment_id};
use crate::document::{HeadDedupeRule, HeadRules};
use crate::href::PageHrefs;
use crate::outcome::ResolveTarget;
use crate::plan::{CurrentPlan, Declarations, Plan, PlanError};
use crate::service::{AppService, AppServiceInner, StaticPublicAssetsLayer};
const DEV_DIST_DIR_ENV_VAR: &str = "VORMA_DIST_DIR";
const PROD_BUILD_DIR_NAME: &str = "prod";
const DEV_BUILD_DIR_NAME: &str = "dev";
pub struct App<State> {
pub prod_dist_dir: PathBuf,
pub task_middlewares: Vec<TaskMiddleware<State>>,
pub view_loaders: Vec<ViewLoader<State>>,
pub api_endpoints: Vec<ApiEndpoint<State>>,
pub head_dedupe_rules: Vec<HeadDedupeRule>,
pub max_request_body_size: usize,
}
pub struct StartupError {
kind: StartupErrorKind,
message: String,
described: Option<Box<dyn StdError + Send + Sync>>,
}
impl fmt::Debug for StartupError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)?;
let mut cause = StdError::source(self);
while let Some(error) = cause {
write!(formatter, "\n\nCaused by: {error}")?;
cause = error.source();
}
Ok(())
}
}
impl fmt::Display for StartupError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl StdError for StartupError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.described
.as_deref()
.and_then(|described| described.source())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StartupErrorKind {
Config,
BuildOutput,
Declarations,
DeclarationsMismatch,
Plan,
}
impl StartupError {
pub fn kind(&self) -> StartupErrorKind {
self.kind
}
fn from_message(kind: StartupErrorKind, message: String) -> Self {
Self {
kind,
message,
described: None,
}
}
fn from_error(kind: StartupErrorKind, error: impl StdError + Send + Sync + 'static) -> Self {
Self {
kind,
message: error.to_string(),
described: Some(Box::new(error)),
}
}
}
impl<State: Send + Sync + 'static> App<State> {
fn clone_declarations(&self) -> Declarations<State> {
Declarations {
task_middlewares: self.task_middlewares.clone(),
view_loaders: self.view_loaders.clone(),
api_endpoints: self.api_endpoints.clone(),
}
}
#[cfg(feature = "__internal")]
#[doc(hidden)]
pub fn __declarations_hash(&self) -> Result<String, StartupError> {
describe(&self.clone_declarations())
.map(|description| description.declarations_hash)
.map_err(|error| StartupError::from_error(StartupErrorKind::Declarations, error))
}
fn read_build_dir(&self) -> Result<PathBuf, StartupError> {
if let Some(staged) = std::env::var_os(CHECK_BUILD_DIR_ENV_VAR) {
return Ok(PathBuf::from(staged));
}
if is_dev() {
return match std::env::var_os(DEV_DIST_DIR_ENV_VAR) {
Some(value) if !value.is_empty() => {
Ok(PathBuf::from(value).join(DEV_BUILD_DIR_NAME))
}
_ => Err(StartupError::from_message(
StartupErrorKind::Config,
format!(
"`vorma dev` started this process without naming the dist directory in {DEV_DIST_DIR_ENV_VAR}"
),
)),
};
}
if self.prod_dist_dir.as_os_str().is_empty() {
return Err(StartupError::from_message(
StartupErrorKind::Config,
"prod_dist_dir is empty; set it to the dist directory that `vorma build` wrote the production build in"
.to_owned(),
));
}
Ok(self.prod_dist_dir.join(PROD_BUILD_DIR_NAME))
}
pub async fn load(self) -> Result<LoadedApp<State>, StartupError> {
let declarations = self.clone_declarations();
let description = describe(&declarations);
write_contract_if_requested(&description);
let loaded = self.load_build_dir(declarations, description).await;
if std::env::var_os(CHECK_BUILD_DIR_ENV_VAR).is_some() {
match loaded {
Ok(_) => std::process::exit(0),
Err(error) => {
eprintln!("{error:?}");
std::process::exit(1);
}
}
}
loaded
}
async fn load_build_dir(
self,
declarations: Declarations<State>,
description: Result<DeclarationDescription, ContractError>,
) -> Result<LoadedApp<State>, StartupError> {
let description = match description {
Ok(description) => description,
Err(error) => {
let snapshot = async {
let build = BuildOutput::load(&self.read_build_dir().ok()?).await.ok()?;
build.read_snapshot().await.ok()
}
.await;
return Err(report_description_error(error, &declarations, snapshot));
}
};
let build_dir = self.read_build_dir()?;
let build = BuildOutput::load(&build_dir)
.await
.map_err(|error| StartupError::from_error(StartupErrorKind::BuildOutput, error))?;
let snapshot = build
.read_snapshot()
.await
.map_err(|error| StartupError::from_error(StartupErrorKind::BuildOutput, error))?;
match (snapshot.metadata.runtime_mode, is_dev()) {
(RuntimeMode::Dev, false) => {
return Err(StartupError::from_message(
StartupErrorKind::BuildOutput,
format!(
"the build directory {} holds a development build, but `vorma dev` did not start this process, so it runs production builds only. Run `vorma build`, which writes the production build to `prod` in the dist directory.",
build_dir.display()
),
));
}
(RuntimeMode::Prod, true) => {
return Err(StartupError::from_message(
StartupErrorKind::BuildOutput,
format!(
"the build directory {} holds a production build, but `vorma dev` started this process, so it runs development builds only. `vorma dev` writes the development build to `dev` in the dist directory that it names.",
build_dir.display()
),
));
}
(RuntimeMode::Dev, true) | (RuntimeMode::Prod, false) => {}
}
if snapshot.metadata.declarations_hash != description.declarations_hash {
return Err(StartupError::from_message(
StartupErrorKind::DeclarationsMismatch,
format!(
"the build directory {} was built for other declarations than this binary has. Run `vorma build` with `build.serverStartCmd` running this binary, and deploy the build directory that it writes together with the binary.",
build_dir.display()
),
));
}
self.finish_load(declarations, build, snapshot)
}
pub fn load_for_tests(self, build: TestBuild) -> Result<LoadedApp<State>, StartupError> {
if build.client_build_id.is_empty()
|| http::HeaderValue::from_str(&build.client_build_id).is_err()
{
return Err(StartupError::from_message(
StartupErrorKind::BuildOutput,
"the client build ID of the test build is empty or is not a valid header value"
.to_owned(),
));
}
let declarations = self.clone_declarations();
let (declarations_hash, description_error) = match describe(&declarations) {
Ok(description) => (description.declarations_hash, None),
Err(error) => (String::new(), Some(error)),
};
let snapshot = Arc::new(BuildSnapshot::new(build.into_metadata(declarations_hash)));
if let Some(error) = description_error {
return Err(report_description_error(
error,
&declarations,
Some(snapshot),
));
}
let build = BuildOutput::from_snapshot(Arc::clone(&snapshot));
self.finish_load(declarations, build, snapshot)
}
fn finish_load(
self,
declarations: Declarations<State>,
build: BuildOutput,
snapshot: Arc<BuildSnapshot>,
) -> Result<LoadedApp<State>, StartupError> {
let plan = Plan::compile(&declarations, snapshot)
.map_err(|error| StartupError::from_error(StartupErrorKind::Plan, error))?;
let deployment_id = read_vercel_deployment_id()
.map_err(|message| StartupError::from_message(StartupErrorKind::Config, message))?;
if deployment_id.is_some() {
let problems = declarations.find_skew_protection_search_problems();
if !problems.is_empty() {
return Err(StartupError::from_error(
StartupErrorKind::Plan,
PlanError { problems },
));
}
}
Ok(LoadedApp {
declarations: Arc::new(declarations),
head_rules: Arc::new(HeadRules::new(&self.head_dedupe_rules)),
build: Arc::new(build),
plan: Arc::new(CurrentPlan::new(Arc::new(plan))),
max_request_body_size: self.max_request_body_size,
deployment_id,
})
}
}
fn report_description_error<State: Send + Sync + 'static>(
error: ContractError,
declarations: &Declarations<State>,
snapshot: Option<Arc<BuildSnapshot>>,
) -> StartupError {
let plan_error = snapshot.and_then(|snapshot| Plan::compile(declarations, snapshot).err());
let mut message = error.to_string();
if let Some(plan_error) = plan_error {
let unreported: Vec<String> = plan_error
.problems
.into_iter()
.filter(|problem| !message.contains(problem.as_str()))
.collect();
if !unreported.is_empty() {
message.push('\n');
message.push_str(
&PlanError {
problems: unreported,
}
.to_string(),
);
}
}
StartupError {
kind: StartupErrorKind::Declarations,
message,
described: Some(Box::new(error)),
}
}
pub struct LoadedApp<State> {
declarations: Arc<Declarations<State>>,
head_rules: Arc<HeadRules>,
build: Arc<BuildOutput>,
plan: Arc<CurrentPlan<State>>,
max_request_body_size: usize,
deployment_id: Option<String>,
}
impl<State: Send + Sync + 'static> LoadedApp<State> {
pub fn service(&self, task_runtime: TaskRuntime<State>) -> AppService<State> {
AppService::new(AppServiceInner {
declarations: Arc::clone(&self.declarations),
head_rules: Arc::clone(&self.head_rules),
build: Arc::clone(&self.build),
plan: Arc::clone(&self.plan),
max_request_body_size: self.max_request_body_size,
tasks: task_runtime,
deployment_id: self.deployment_id.clone(),
})
}
pub fn page_hrefs(&self) -> PageHrefs {
PageHrefs::new(Arc::clone(&self.plan) as Arc<dyn ResolveTarget>)
}
pub fn static_public_assets_layer(&self) -> StaticPublicAssetsLayer {
StaticPublicAssetsLayer::new(Arc::clone(&self.build))
}
}