leptos_config/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod errors;
4
5use crate::errors::LeptosConfigError;
6use config::{Case, Config, File, FileFormat};
7use regex::Regex;
8use std::{
9    env::VarError, fs, net::SocketAddr, path::Path, str::FromStr, sync::Arc,
10};
11use typed_builder::TypedBuilder;
12
13/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
14/// occur with LeptosOptions
15#[derive(Clone, Debug, serde::Deserialize)]
16#[serde(rename_all = "kebab-case")]
17#[non_exhaustive]
18pub struct ConfFile {
19    pub leptos_options: LeptosOptions,
20}
21
22/// This struct serves as a convenient place to store details used for configuring Leptos.
23/// It's used in our actix and axum integrations to generate the
24/// correct path for WASM, JS, and Websockets, as well as other configuration tasks.
25/// It shares keys with cargo-leptos, to allow for easy interoperability
26#[derive(TypedBuilder, Debug, Clone, serde::Deserialize)]
27#[serde(rename_all = "kebab-case")]
28#[non_exhaustive]
29pub struct LeptosOptions {
30    /// The name of the WASM and JS files generated by wasm-bindgen.
31    ///
32    /// This should match the name that will be output when building your application.
33    ///
34    /// You can easily set this using `env!("CARGO_CRATE_NAME")`.
35    #[builder(setter(into))]
36    pub output_name: Arc<str>,
37    /// The path of the all the files generated by cargo-leptos. This defaults to '.' for convenience when integrating with other
38    /// tools.
39    #[builder(setter(into), default=default_site_root())]
40    #[serde(default = "default_site_root")]
41    pub site_root: Arc<str>,
42    /// The path of the WASM and JS files generated by wasm-bindgen from the root of your app
43    /// By default, wasm-bindgen puts them in `pkg`.
44    #[builder(setter(into), default=default_site_pkg_dir())]
45    #[serde(default = "default_site_pkg_dir")]
46    pub site_pkg_dir: Arc<str>,
47    /// Used to configure the running environment of Leptos. Can be used to load dev constants and keys v prod, or change
48    /// things based on the deployment environment
49    /// I recommend passing in the result of `env::var("LEPTOS_ENV")`
50    #[builder(setter(into), default=default_env())]
51    #[serde(default = "default_env")]
52    pub env: Env,
53    /// Provides a way to control the address leptos is served from.
54    /// Using an env variable here would allow you to run the same code in dev and prod
55    /// Defaults to `127.0.0.1:3000`
56    #[builder(setter(into), default=default_site_addr())]
57    #[serde(default = "default_site_addr")]
58    pub site_addr: SocketAddr,
59    /// The port the Websocket watcher listens on. Should match the `reload_port` in cargo-leptos(if using).
60    /// Defaults to `3001`
61    #[builder(default = default_reload_port())]
62    #[serde(default = "default_reload_port")]
63    pub reload_port: u32,
64    /// The port the Websocket watcher listens on when on the client, e.g., when behind a reverse proxy.
65    /// Defaults to match reload_port
66    #[builder(default)]
67    #[serde(default)]
68    pub reload_external_port: Option<u32>,
69    /// The protocol the Websocket watcher uses on the client: `ws` in most cases, `wss` when behind a reverse https proxy.
70    /// Defaults to `ws`
71    #[builder(default)]
72    #[serde(default)]
73    pub reload_ws_protocol: ReloadWSProtocol,
74    /// The path of a custom 404 Not Found page to display when statically serving content, defaults to `site_root/404.html`
75    #[builder(default = default_not_found_path())]
76    #[serde(default = "default_not_found_path")]
77    pub not_found_path: Arc<str>,
78    /// The file name of the hash text file generated by cargo-leptos. Defaults to `hash.txt`.
79    #[builder(default = default_hash_file_name())]
80    #[serde(default = "default_hash_file_name")]
81    pub hash_file: Arc<str>,
82    /// If true, hashes will be generated for all files in the site_root and added to their file names.
83    /// Defaults to `false`.
84    #[builder(default = default_hash_files())]
85    #[serde(default = "default_hash_files")]
86    pub hash_files: bool,
87    /// The default prefix to use for server functions when generating API routes. Can be
88    /// overridden for individual functions using `#[server(prefix = "...")]` as usual.
89    ///
90    /// This is useful to override the default prefix (`/api`) for all server functions without
91    /// needing to manually specify via `#[server(prefix = "...")]` on every server function.
92    #[builder(default, setter(strip_option))]
93    #[serde(default)]
94    pub server_fn_prefix: Option<String>,
95    /// Whether to disable appending the server functions' hashes to the end of their API names.
96    ///
97    /// This is useful when an app's client side needs a stable server API. For example, shipping
98    /// the CSR WASM binary in a Tauri app. Tauri app releases are dependent on each platform's
99    /// distribution method (e.g., the Apple App Store or the Google Play Store), which typically
100    /// are much slower than the frequency at which a website can be updated. In addition, it's
101    /// common for users to not have the latest app version installed. In these cases, the CSR WASM
102    /// app would need to be able to continue calling the backend server function API, so the API
103    /// path needs to be consistent and not have a hash appended.
104    ///
105    /// Note that the hash suffixes is intended as a way to ensure duplicate API routes are created.
106    /// Without the hash, server functions will need to have unique names to avoid creating
107    /// duplicate routes. Axum will throw an error if a duplicate route is added to the router, but
108    /// Actix will not.
109    #[builder(default)]
110    #[serde(default)]
111    pub disable_server_fn_hash: bool,
112    /// Include the module path of the server function in the API route. This is an alternative
113    /// strategy to prevent duplicate server function API routes (the default strategy is to add
114    /// a hash to the end of the route). Each element of the module path will be separated by a `/`.
115    /// For example, a server function with a fully qualified name of `parent::child::server_fn`
116    /// would have an API route of `/api/parent/child/server_fn` (possibly with a
117    /// different prefix and a hash suffix depending on the values of the other server fn configs).
118    #[builder(default)]
119    #[serde(default)]
120    pub server_fn_mod_path: bool,
121}
122
123impl LeptosOptions {
124    fn try_from_env() -> Result<Self, LeptosConfigError> {
125        let output_name = env_w_default(
126            "LEPTOS_OUTPUT_NAME",
127            std::option_env!("LEPTOS_OUTPUT_NAME",).unwrap_or_default(),
128        )?;
129        if output_name.is_empty() {
130            eprintln!(
131                "It looks like you're trying to compile Leptos without the \
132                 LEPTOS_OUTPUT_NAME environment variable being set. There are \
133                 two options\n 1. cargo-leptos is not being used, but \
134                 get_configuration() is being passed None. This needs to be \
135                 changed to Some(\"Cargo.toml\")\n 2. You are compiling \
136                 Leptos without LEPTOS_OUTPUT_NAME being set with \
137                 cargo-leptos. This shouldn't be possible!"
138            );
139        }
140        Ok(LeptosOptions {
141            output_name: output_name.into(),
142            site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?.into(),
143            site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?.into(),
144            env: env_from_str(env_w_default("LEPTOS_ENV", "DEV")?.as_str())?,
145            site_addr: env_w_default("LEPTOS_SITE_ADDR", "127.0.0.1:3000")?
146                .parse()?,
147            reload_port: env_w_default("LEPTOS_RELOAD_PORT", "3001")?
148                .parse()?,
149            reload_external_port: match env_wo_default(
150                "LEPTOS_RELOAD_EXTERNAL_PORT",
151            )? {
152                Some(val) => Some(val.parse()?),
153                None => None,
154            },
155            reload_ws_protocol: ws_from_str(
156                env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(),
157            )?,
158            not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?
159                .into(),
160            hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?
161                .into(),
162            hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
163            server_fn_prefix: env_wo_default("SERVER_FN_PREFIX")?,
164            disable_server_fn_hash: env_wo_default("DISABLE_SERVER_FN_HASH")?
165                .is_some(),
166            server_fn_mod_path: env_wo_default("SERVER_FN_MOD_PATH")?.is_some(),
167        })
168    }
169}
170
171fn default_site_root() -> Arc<str> {
172    ".".into()
173}
174
175fn default_site_pkg_dir() -> Arc<str> {
176    "pkg".into()
177}
178
179fn default_env() -> Env {
180    Env::DEV
181}
182
183fn default_site_addr() -> SocketAddr {
184    SocketAddr::from(([127, 0, 0, 1], 3000))
185}
186
187fn default_reload_port() -> u32 {
188    3001
189}
190
191fn default_not_found_path() -> Arc<str> {
192    "/404".into()
193}
194
195fn default_hash_file_name() -> Arc<str> {
196    "hash.txt".into()
197}
198
199fn default_hash_files() -> bool {
200    false
201}
202
203fn env_wo_default(key: &str) -> Result<Option<String>, LeptosConfigError> {
204    match std::env::var(key) {
205        Ok(val) => Ok(Some(val)),
206        Err(VarError::NotPresent) => Ok(None),
207        Err(e) => Err(LeptosConfigError::EnvVarError(format!("{key}: {e}"))),
208    }
209}
210fn env_w_default(
211    key: &str,
212    default: &str,
213) -> Result<String, LeptosConfigError> {
214    match std::env::var(key) {
215        Ok(val) => Ok(val),
216        Err(VarError::NotPresent) => Ok(default.to_string()),
217        Err(e) => Err(LeptosConfigError::EnvVarError(format!("{key}: {e}"))),
218    }
219}
220
221/// An enum that can be used to define the environment Leptos is running in.
222/// Setting this to the `PROD` variant will not include the WebSocket code for `cargo-leptos` watch mode.
223/// Defaults to `DEV`.
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
225pub enum Env {
226    PROD,
227    DEV,
228}
229
230impl Default for Env {
231    fn default() -> Self {
232        Self::DEV
233    }
234}
235
236fn env_from_str(input: &str) -> Result<Env, LeptosConfigError> {
237    let sanitized = input.to_lowercase();
238    match sanitized.as_ref() {
239        "dev" | "development" => Ok(Env::DEV),
240        "prod" | "production" => Ok(Env::PROD),
241        _ => Err(LeptosConfigError::EnvVarError(format!(
242            "{input} is not a supported environment. Use either `dev` or \
243             `production`.",
244        ))),
245    }
246}
247
248impl FromStr for Env {
249    type Err = ();
250    fn from_str(input: &str) -> Result<Self, Self::Err> {
251        env_from_str(input).or_else(|_| Ok(Self::default()))
252    }
253}
254
255impl From<&str> for Env {
256    fn from(str: &str) -> Self {
257        env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
258    }
259}
260
261impl From<&Result<String, VarError>> for Env {
262    fn from(input: &Result<String, VarError>) -> Self {
263        match input {
264            Ok(str) => {
265                env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
266            }
267            Err(_) => Self::default(),
268        }
269    }
270}
271
272impl TryFrom<String> for Env {
273    type Error = LeptosConfigError;
274
275    fn try_from(s: String) -> Result<Self, Self::Error> {
276        env_from_str(s.as_str())
277    }
278}
279
280/// An enum that can be used to define the websocket protocol Leptos uses for hotreloading
281/// Defaults to `ws`.
282#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
283pub enum ReloadWSProtocol {
284    WS,
285    WSS,
286}
287
288impl Default for ReloadWSProtocol {
289    fn default() -> Self {
290        Self::WS
291    }
292}
293
294fn ws_from_str(input: &str) -> Result<ReloadWSProtocol, LeptosConfigError> {
295    let sanitized = input.to_lowercase();
296    match sanitized.as_ref() {
297        "ws" | "WS" => Ok(ReloadWSProtocol::WS),
298        "wss" | "WSS" => Ok(ReloadWSProtocol::WSS),
299        _ => Err(LeptosConfigError::EnvVarError(format!(
300            "{input} is not a supported websocket protocol. Use only `ws` or \
301             `wss`.",
302        ))),
303    }
304}
305
306impl FromStr for ReloadWSProtocol {
307    type Err = ();
308    fn from_str(input: &str) -> Result<Self, Self::Err> {
309        ws_from_str(input).or_else(|_| Ok(Self::default()))
310    }
311}
312
313impl From<&str> for ReloadWSProtocol {
314    fn from(str: &str) -> Self {
315        ws_from_str(str).unwrap_or_else(|err| panic!("{}", err))
316    }
317}
318
319impl From<&Result<String, VarError>> for ReloadWSProtocol {
320    fn from(input: &Result<String, VarError>) -> Self {
321        match input {
322            Ok(str) => ws_from_str(str).unwrap_or_else(|err| panic!("{}", err)),
323            Err(_) => Self::default(),
324        }
325    }
326}
327
328impl TryFrom<String> for ReloadWSProtocol {
329    type Error = LeptosConfigError;
330
331    fn try_from(s: String) -> Result<Self, Self::Error> {
332        ws_from_str(s.as_str())
333    }
334}
335
336/// Loads [LeptosOptions] from a Cargo.toml text content with layered overrides.
337/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
338pub fn get_config_from_str(
339    text: &str,
340) -> Result<LeptosOptions, LeptosConfigError> {
341    let re: Regex = Regex::new(r"(?m)^\[package.metadata.leptos\]").unwrap();
342    let re_workspace: Regex =
343        Regex::new(r"(?m)^\[\[workspace.metadata.leptos\]\]").unwrap();
344
345    let metadata_name;
346    let start;
347    match re.find(text) {
348        Some(found) => {
349            metadata_name = "[package.metadata.leptos]";
350            start = found.start();
351        }
352        None => match re_workspace.find(text) {
353            Some(found) => {
354                metadata_name = "[[workspace.metadata.leptos]]";
355                start = found.start();
356            }
357            None => return Err(LeptosConfigError::ConfigSectionNotFound),
358        },
359    };
360
361    // so that serde error messages have right line number
362    let newlines = text[..start].matches('\n').count();
363    let input = "\n".repeat(newlines) + &text[start..];
364    // so the settings will be interpreted as root level settings
365    let toml = input.replace(metadata_name, "");
366    let settings = Config::builder()
367        // Read the "default" configuration file
368        .add_source(File::from_str(&toml, FileFormat::Toml))
369        // Layer on the environment-specific values.
370        // Add in settings from environment variables (with a prefix of LEPTOS)
371        // E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
372        .add_source(
373            config::Environment::with_prefix("LEPTOS")
374                .convert_case(Case::Kebab),
375        )
376        .build()?;
377
378    settings
379        .try_deserialize()
380        .map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
381}
382
383/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. If an env var is specified, like `LEPTOS_ENV`,
384/// it will override a setting in the file. It takes in an optional path to a Cargo.toml file. If None is provided,
385/// you'll need to set the options as environment variables or rely on the defaults. This is the preferred
386/// approach for cargo-leptos. If Some("./Cargo.toml") is provided, Leptos will read in the settings itself. This
387/// option currently does not allow dashes in file or folder names, as all dashes become underscores
388pub fn get_configuration(
389    path: Option<&str>,
390) -> Result<ConfFile, LeptosConfigError> {
391    if let Some(path) = path {
392        get_config_from_file(path)
393    } else {
394        get_config_from_env()
395    }
396}
397
398/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. Leptos will read in the settings itself. This
399/// option currently does not allow dashes in file or folder names, as all dashes become underscores
400pub fn get_config_from_file<P: AsRef<Path>>(
401    path: P,
402) -> Result<ConfFile, LeptosConfigError> {
403    let text = fs::read_to_string(path)
404        .map_err(|_| LeptosConfigError::ConfigNotFound)?;
405    let leptos_options = get_config_from_str(&text)?;
406    Ok(ConfFile { leptos_options })
407}
408
409/// Loads [LeptosOptions] from environment variables or rely on the defaults
410pub fn get_config_from_env() -> Result<ConfFile, LeptosConfigError> {
411    Ok(ConfFile {
412        leptos_options: LeptosOptions::try_from_env()?,
413    })
414}
415
416#[path = "tests.rs"]
417#[cfg(test)]
418mod tests;