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(
225    Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default,
226)]
227pub enum Env {
228    PROD,
229    #[default]
230    DEV,
231}
232
233fn env_from_str(input: &str) -> Result<Env, LeptosConfigError> {
234    let sanitized = input.to_lowercase();
235    match sanitized.as_ref() {
236        "dev" | "development" => Ok(Env::DEV),
237        "prod" | "production" => Ok(Env::PROD),
238        _ => Err(LeptosConfigError::EnvVarError(format!(
239            "{input} is not a supported environment. Use either `dev` or \
240             `production`.",
241        ))),
242    }
243}
244
245impl FromStr for Env {
246    type Err = ();
247    fn from_str(input: &str) -> Result<Self, Self::Err> {
248        env_from_str(input).or_else(|_| Ok(Self::default()))
249    }
250}
251
252impl From<&str> for Env {
253    fn from(str: &str) -> Self {
254        env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
255    }
256}
257
258impl From<&Result<String, VarError>> for Env {
259    fn from(input: &Result<String, VarError>) -> Self {
260        match input {
261            Ok(str) => {
262                env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
263            }
264            Err(_) => Self::default(),
265        }
266    }
267}
268
269impl TryFrom<String> for Env {
270    type Error = LeptosConfigError;
271
272    fn try_from(s: String) -> Result<Self, Self::Error> {
273        env_from_str(s.as_str())
274    }
275}
276
277/// An enum that can be used to define the websocket protocol Leptos uses for hotreloading
278/// Defaults to `ws`.
279#[derive(
280    Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default,
281)]
282pub enum ReloadWSProtocol {
283    #[default]
284    WS,
285    WSS,
286}
287
288fn ws_from_str(input: &str) -> Result<ReloadWSProtocol, LeptosConfigError> {
289    let sanitized = input.to_lowercase();
290    match sanitized.as_ref() {
291        "ws" | "WS" => Ok(ReloadWSProtocol::WS),
292        "wss" | "WSS" => Ok(ReloadWSProtocol::WSS),
293        _ => Err(LeptosConfigError::EnvVarError(format!(
294            "{input} is not a supported websocket protocol. Use only `ws` or \
295             `wss`.",
296        ))),
297    }
298}
299
300impl FromStr for ReloadWSProtocol {
301    type Err = ();
302    fn from_str(input: &str) -> Result<Self, Self::Err> {
303        ws_from_str(input).or_else(|_| Ok(Self::default()))
304    }
305}
306
307impl From<&str> for ReloadWSProtocol {
308    fn from(str: &str) -> Self {
309        ws_from_str(str).unwrap_or_else(|err| panic!("{}", err))
310    }
311}
312
313impl From<&Result<String, VarError>> for ReloadWSProtocol {
314    fn from(input: &Result<String, VarError>) -> Self {
315        match input {
316            Ok(str) => ws_from_str(str).unwrap_or_else(|err| panic!("{}", err)),
317            Err(_) => Self::default(),
318        }
319    }
320}
321
322impl TryFrom<String> for ReloadWSProtocol {
323    type Error = LeptosConfigError;
324
325    fn try_from(s: String) -> Result<Self, Self::Error> {
326        ws_from_str(s.as_str())
327    }
328}
329
330/// Loads [LeptosOptions] from a Cargo.toml text content with layered overrides.
331/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
332pub fn get_config_from_str(
333    text: &str,
334) -> Result<LeptosOptions, LeptosConfigError> {
335    let re: Regex = Regex::new(r"(?m)^\[package.metadata.leptos\]").unwrap();
336    let re_workspace: Regex =
337        Regex::new(r"(?m)^\[\[workspace.metadata.leptos\]\]").unwrap();
338
339    let metadata_name;
340    let start;
341    match re.find(text) {
342        Some(found) => {
343            metadata_name = "[package.metadata.leptos]";
344            start = found.start();
345        }
346        None => match re_workspace.find(text) {
347            Some(found) => {
348                metadata_name = "[[workspace.metadata.leptos]]";
349                start = found.start();
350            }
351            None => return Err(LeptosConfigError::ConfigSectionNotFound),
352        },
353    };
354
355    // so that serde error messages have right line number
356    let newlines = text[..start].matches('\n').count();
357    let input = "\n".repeat(newlines) + &text[start..];
358    // so the settings will be interpreted as root level settings
359    let toml = input.replace(metadata_name, "");
360    let settings = Config::builder()
361        // Read the "default" configuration file
362        .add_source(File::from_str(&toml, FileFormat::Toml))
363        // Layer on the environment-specific values.
364        // Add in settings from environment variables (with a prefix of LEPTOS)
365        // E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
366        .add_source(
367            config::Environment::with_prefix("LEPTOS")
368                .convert_case(Case::Kebab),
369        )
370        .build()?;
371
372    settings
373        .try_deserialize()
374        .map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
375}
376
377/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. If an env var is specified, like `LEPTOS_ENV`,
378/// it will override a setting in the file. It takes in an optional path to a Cargo.toml file. If None is provided,
379/// you'll need to set the options as environment variables or rely on the defaults. This is the preferred
380/// approach for cargo-leptos. If Some("./Cargo.toml") is provided, Leptos will read in the settings itself. This
381/// option currently does not allow dashes in file or folder names, as all dashes become underscores
382pub fn get_configuration(
383    path: Option<&str>,
384) -> Result<ConfFile, LeptosConfigError> {
385    if let Some(path) = path {
386        get_config_from_file(path)
387    } else {
388        get_config_from_env()
389    }
390}
391
392/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. Leptos will read in the settings itself. This
393/// option currently does not allow dashes in file or folder names, as all dashes become underscores
394pub fn get_config_from_file<P: AsRef<Path>>(
395    path: P,
396) -> Result<ConfFile, LeptosConfigError> {
397    let text = fs::read_to_string(path)
398        .map_err(|_| LeptosConfigError::ConfigNotFound)?;
399    let leptos_options = get_config_from_str(&text)?;
400    Ok(ConfFile { leptos_options })
401}
402
403/// Loads [LeptosOptions] from environment variables or rely on the defaults
404pub fn get_config_from_env() -> Result<ConfFile, LeptosConfigError> {
405    Ok(ConfFile {
406        leptos_options: LeptosOptions::try_from_env()?,
407    })
408}
409
410#[path = "tests.rs"]
411#[cfg(test)]
412mod tests;