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        // The owner's own edge terminates the visitor connection, so the domain
78        // serves as soon as their DNS points at the origin — there is no fn0-side
79        // DV state to wait on. The origin certificate is what fn0 must hold.
80        Ok(DomainStatus::SelfHosted {
81            domain,
82            origin_certificate_ready: true,
83            ..
84        }) => ResolvedAppUrl {
85            url: format!("https://{domain}"),
86            note: None,
87        },
88        Ok(DomainStatus::SelfHosted { domain, .. }) => fallback(format!(
89            "custom domain '{domain}' has no origin certificate yet, so the default \
90             subdomain is used"
91        )),
92        Ok(DomainStatus::NotConfigured) => ResolvedAppUrl {
93            url: default_url,
94            note: None,
95        },
96        Ok(DomainStatus::NotLoggedIn) => {
97            fallback("control rejected the saved token; run `forte login` again".to_string())
98        }
99        Ok(DomainStatus::NotFound) => fallback(format!(
100            "project '{project_id}' was not found under your account; nothing may be deployed there"
101        )),
102        Ok(DomainStatus::InternalError) => {
103            fallback("control failed to report the custom domain".to_string())
104        }
105        Err(e) => fallback(format!(
106            "could not reach control to check a custom domain: {e}"
107        )),
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn builds_subdomain_under_the_control_host() {
117        assert_eq!(
118            default_app_url("https://fn0.dev", "abc123").unwrap(),
119            "https://abc123.fn0.dev"
120        );
121    }
122
123    #[test]
124    fn keeps_scheme_and_port_of_a_self_hosted_control() {
125        assert_eq!(
126            default_app_url("http://localhost:3000", "abc123").unwrap(),
127            "http://abc123.localhost:3000"
128        );
129    }
130
131    #[test]
132    fn drops_a_trailing_path_on_the_control_url() {
133        assert_eq!(
134            default_app_url("https://fn0.dev/", "abc123").unwrap(),
135            "https://abc123.fn0.dev"
136        );
137    }
138
139    #[test]
140    fn strips_the_control_projects_own_subdomain_to_reach_the_apex() {
141        assert_eq!(
142            default_app_url("https://fn0-control.fn0.dev", "abc123").unwrap(),
143            "https://abc123.fn0.dev"
144        );
145    }
146}