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