Skip to main content

fn0_deploy/
app_url.rs

1//! Resolves the public URL a deployed project is served at.
2//!
3//! A project is always reachable at `{project_id}.{control host}`, because the
4//! worker routes on the first host label. A custom domain, when attached and
5//! active, is preferred over that default subdomain.
6
7use anyhow::{Result, anyhow};
8
9use crate::domain::{
10    CloudflareStatus, DomainStatus, fetch_domain_status, format_cloudflare_status,
11};
12
13pub struct ResolvedAppUrl {
14    pub url: String,
15    pub note: Option<String>,
16}
17
18pub fn default_app_url(control_url: &str, project_id: &str) -> Result<String> {
19    let mut url = reqwest::Url::parse(control_url)
20        .map_err(|e| anyhow!("control URL '{control_url}' is not a valid URL: {e}"))?;
21    let host = url
22        .host_str()
23        .ok_or_else(|| anyhow!("control URL '{control_url}' has no host"))?;
24    let app_host = format!("{project_id}.{host}");
25    url.set_host(Some(&app_host))
26        .map_err(|e| anyhow!("could not build app host '{app_host}': {e}"))?;
27    url.set_path("");
28    Ok(url.to_string().trim_end_matches('/').to_string())
29}
30
31pub async fn resolve_app_url(project_id: &str) -> Result<ResolvedAppUrl> {
32    let creds = crate::credentials::load()?;
33    let control_url = creds
34        .as_ref()
35        .map(|c| c.control_url.clone())
36        .unwrap_or_else(crate::credentials::default_control_url);
37    let default_url = default_app_url(&control_url, project_id)?;
38
39    let Some(creds) = creds else {
40        return Ok(ResolvedAppUrl {
41            url: default_url,
42            note: Some("not signed in, so a custom domain could not be checked".to_string()),
43        });
44    };
45
46    let fallback = |note: String| ResolvedAppUrl {
47        url: default_url.clone(),
48        note: Some(note),
49    };
50
51    Ok(match fetch_domain_status(&creds, project_id).await {
52        Ok(DomainStatus::Configured {
53            domain,
54            cloudflare_status: CloudflareStatus::Active,
55        }) => ResolvedAppUrl {
56            url: format!("https://{domain}"),
57            note: None,
58        },
59        Ok(DomainStatus::Configured {
60            domain,
61            cloudflare_status,
62        }) => fallback(format!(
63            "custom domain '{domain}' is not serving yet ({}), so the default subdomain is used",
64            format_cloudflare_status(&cloudflare_status)
65        )),
66        Ok(DomainStatus::NotConfigured) => ResolvedAppUrl {
67            url: default_url,
68            note: None,
69        },
70        Ok(DomainStatus::NotLoggedIn) => {
71            fallback("control rejected the saved token; run `forte login` again".to_string())
72        }
73        Ok(DomainStatus::NotFound) => fallback(format!(
74            "project '{project_id}' was not found under your account; nothing may be deployed there"
75        )),
76        Ok(DomainStatus::InternalError) => {
77            fallback("control failed to report the custom domain".to_string())
78        }
79        Err(e) => fallback(format!(
80            "could not reach control to check a custom domain: {e}"
81        )),
82    })
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn builds_subdomain_under_the_control_host() {
91        assert_eq!(
92            default_app_url("https://fn0.dev", "abc123").unwrap(),
93            "https://abc123.fn0.dev"
94        );
95    }
96
97    #[test]
98    fn keeps_scheme_and_port_of_a_self_hosted_control() {
99        assert_eq!(
100            default_app_url("http://localhost:3000", "abc123").unwrap(),
101            "http://abc123.localhost:3000"
102        );
103    }
104
105    #[test]
106    fn drops_a_trailing_path_on_the_control_url() {
107        assert_eq!(
108            default_app_url("https://fn0.dev/", "abc123").unwrap(),
109            "https://abc123.fn0.dev"
110        );
111    }
112}