Skip to main content

rusty_ssr/
config.rs

1//! Configuration for Rusty SSR engine
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::error::{SsrError, SsrResult};
7
8/// Configuration for the SSR engine
9#[derive(Debug, Clone)]
10pub struct SsrConfig {
11    /// Path to the JavaScript SSR bundle
12    pub bundle_path: PathBuf,
13
14    /// Number of V8 worker threads (default: number of CPUs)
15    pub pool_size: usize,
16
17    /// Size of the task queue for V8 pool
18    pub queue_capacity: usize,
19
20    /// Pin V8 workers to specific CPU cores
21    pub pin_threads: bool,
22
23    /// Maximum entries in the SSR cache
24    pub cache_size: usize,
25
26    /// Cache TTL (None = no expiration)
27    pub cache_ttl: Option<Duration>,
28
29    /// Request timeout for enqueueing render jobs
30    pub request_timeout: Option<Duration>,
31
32    /// Name of the global render function in JS bundle
33    pub render_function: String,
34
35    /// Path to an HTML template with SSR placeholders (optional)
36    ///
37    /// When set, the engine injects rendered HTML into the template
38    /// instead of returning raw fragments. Supported placeholders:
39    /// - `<!--ssr:outlet-->` — rendered app HTML
40    /// - `<!--ssr:css-->`    — `<link>` tags from Vite manifest
41    /// - `<!--ssr:scripts-->` — `<script>` tags from Vite manifest
42    /// - `<!--ssr:head-->`   — extra head content (reserved)
43    pub html_template_path: Option<PathBuf>,
44
45    /// Path to Vite manifest.json for hashed asset paths (optional)
46    ///
47    /// Used together with `html_template_path` to inject correct
48    /// `<link>` and `<script>` tags with content-hashed filenames.
49    pub assets_manifest_path: Option<PathBuf>,
50}
51
52impl Default for SsrConfig {
53    fn default() -> Self {
54        Self {
55            bundle_path: PathBuf::from("ssr-bundle.js"),
56            pool_size: num_cpus::get(),
57            queue_capacity: 512,
58            pin_threads: false,
59            cache_size: 300,
60            cache_ttl: Some(Duration::from_secs(300)), // 5 minutes
61            request_timeout: Some(Duration::from_secs(30)),
62            render_function: "renderPage".to_string(),
63            html_template_path: None,
64            assets_manifest_path: None,
65        }
66    }
67}
68
69impl SsrConfig {
70    /// Create a new configuration builder
71    pub fn builder() -> SsrConfigBuilder {
72        SsrConfigBuilder::default()
73    }
74}
75
76/// Builder for SsrConfig
77#[derive(Debug, Default)]
78pub struct SsrConfigBuilder {
79    bundle_path: Option<PathBuf>,
80    pool_size: Option<usize>,
81    queue_capacity: Option<usize>,
82    pin_threads: Option<bool>,
83    cache_size: Option<usize>,
84    cache_ttl: Option<Option<Duration>>,
85    request_timeout: Option<Option<Duration>>,
86    render_function: Option<String>,
87    html_template_path: Option<PathBuf>,
88    assets_manifest_path: Option<PathBuf>,
89}
90
91impl SsrConfigBuilder {
92    /// Set the path to the JavaScript SSR bundle
93    ///
94    /// # Example
95    /// ```rust
96    /// use rusty_ssr::SsrConfig;
97    ///
98    /// let config = SsrConfig::builder()
99    ///     .bundle_path("dist/ssr-bundle.js")
100    ///     .build();
101    /// ```
102    pub fn bundle_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
103        self.bundle_path = Some(path.into());
104        self
105    }
106
107    /// Set the number of V8 worker threads
108    ///
109    /// Default: number of CPU cores
110    pub fn pool_size(mut self, size: usize) -> Self {
111        self.pool_size = Some(size);
112        self
113    }
114
115    /// Set the task queue capacity
116    ///
117    /// Default: 512
118    pub fn queue_capacity(mut self, capacity: usize) -> Self {
119        self.queue_capacity = Some(capacity);
120        self
121    }
122
123    /// Enable CPU core pinning for V8 workers
124    ///
125    /// This can improve cache locality but may reduce flexibility
126    pub fn pin_threads(mut self, pin: bool) -> Self {
127        self.pin_threads = Some(pin);
128        self
129    }
130
131    /// Set the maximum number of cached SSR results
132    ///
133    /// Default: 300
134    pub fn cache_size(mut self, size: usize) -> Self {
135        self.cache_size = Some(size);
136        self
137    }
138
139    /// Set cache TTL (time-to-live)
140    ///
141    /// Default: 5 minutes. Use `None` for no expiration.
142    pub fn cache_ttl(mut self, ttl: Option<Duration>) -> Self {
143        self.cache_ttl = Some(ttl);
144        self
145    }
146
147    /// Set cache TTL in seconds
148    ///
149    /// Convenience method. Use 0 for no expiration.
150    pub fn cache_ttl_secs(mut self, secs: u64) -> Self {
151        self.cache_ttl = Some(if secs > 0 {
152            Some(Duration::from_secs(secs))
153        } else {
154            None
155        });
156        self
157    }
158
159    /// Set request timeout
160    ///
161    /// Default: 30 seconds. Use `None` for no timeout.
162    pub fn request_timeout(mut self, timeout: Option<Duration>) -> Self {
163        self.request_timeout = Some(timeout);
164        self
165    }
166
167    /// Set the HTML template path for SSR output
168    ///
169    /// The template should contain `<!--ssr:outlet-->` where the rendered
170    /// app HTML will be injected. Optionally use `<!--ssr:css-->` and
171    /// `<!--ssr:scripts-->` for Vite manifest-based asset injection.
172    ///
173    /// # Example
174    /// ```rust
175    /// use rusty_ssr::SsrConfig;
176    ///
177    /// let config = SsrConfig::builder()
178    ///     .bundle_path("dist-ssr/bundle.js")
179    ///     .html_template("dist-web/index.html")
180    ///     .assets_manifest("dist-web/.vite/manifest.json")
181    ///     .build();
182    /// ```
183    pub fn html_template<P: Into<PathBuf>>(mut self, path: P) -> Self {
184        self.html_template_path = Some(path.into());
185        self
186    }
187
188    /// Set the Vite manifest.json path for asset resolution
189    ///
190    /// Used with `html_template` to inject hashed CSS and JS paths.
191    pub fn assets_manifest<P: Into<PathBuf>>(mut self, path: P) -> Self {
192        self.assets_manifest_path = Some(path.into());
193        self
194    }
195
196    /// Set the name of the global render function
197    ///
198    /// Default: "renderPage"
199    ///
200    /// Your JS bundle should expose: `globalThis.{render_function}(url, data)`
201    pub fn render_function<S: Into<String>>(mut self, name: S) -> Self {
202        self.render_function = Some(name.into());
203        self
204    }
205
206    /// Build the configuration
207    ///
208    /// # Errors
209    /// Returns `SsrError::Config` if any parameter is invalid:
210    /// - `pool_size` must be > 0
211    /// - `cache_size` must be > 0
212    /// - `queue_capacity` must be > 0
213    /// - `render_function` must be a valid JS identifier (alphanumeric, `_`, `.`)
214    pub fn build(self) -> SsrResult<SsrConfig> {
215        let default = SsrConfig::default();
216
217        let config = SsrConfig {
218            bundle_path: self.bundle_path.unwrap_or(default.bundle_path),
219            pool_size: self.pool_size.unwrap_or(default.pool_size),
220            queue_capacity: self.queue_capacity.unwrap_or(default.queue_capacity),
221            pin_threads: self.pin_threads.unwrap_or(default.pin_threads),
222            cache_size: self.cache_size.unwrap_or(default.cache_size),
223            cache_ttl: self.cache_ttl.unwrap_or(default.cache_ttl),
224            request_timeout: self.request_timeout.unwrap_or(default.request_timeout),
225            render_function: self.render_function.unwrap_or(default.render_function),
226            html_template_path: self.html_template_path,
227            assets_manifest_path: self.assets_manifest_path,
228        };
229
230        if config.pool_size == 0 {
231            return Err(SsrError::Config("pool_size must be > 0".into()));
232        }
233        if config.cache_size == 0 {
234            return Err(SsrError::Config("cache_size must be > 0".into()));
235        }
236        if config.queue_capacity == 0 {
237            return Err(SsrError::Config("queue_capacity must be > 0".into()));
238        }
239        if config.render_function.is_empty()
240            || !config
241                .render_function
242                .chars()
243                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
244        {
245            return Err(SsrError::Config(format!(
246                "render_function must be a valid JS identifier, got: {:?}",
247                config.render_function
248            )));
249        }
250
251        Ok(config)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_default_config() {
261        let config = SsrConfig::default();
262        assert_eq!(config.pool_size, num_cpus::get());
263        assert_eq!(config.cache_size, 300);
264        assert!(!config.pin_threads);
265    }
266
267    #[test]
268    fn test_builder() {
269        let config = SsrConfig::builder()
270            .bundle_path("custom.js")
271            .pool_size(4)
272            .cache_size(100)
273            .pin_threads(true)
274            .build()
275            .unwrap();
276
277        assert_eq!(config.bundle_path, PathBuf::from("custom.js"));
278        assert_eq!(config.pool_size, 4);
279        assert_eq!(config.cache_size, 100);
280        assert!(config.pin_threads);
281    }
282
283    #[test]
284    fn test_zero_pool_size_rejected() {
285        let result = SsrConfig::builder().pool_size(0).build();
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_zero_cache_size_rejected() {
291        let result = SsrConfig::builder().cache_size(0).build();
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_empty_render_function_rejected() {
297        let result = SsrConfig::builder().render_function("").build();
298        assert!(result.is_err());
299    }
300
301    #[test]
302    fn test_invalid_render_function_rejected() {
303        let result = SsrConfig::builder()
304            .render_function("foo; evil()")
305            .build();
306        assert!(result.is_err());
307    }
308
309    #[test]
310    fn test_dotted_render_function_ok() {
311        let config = SsrConfig::builder()
312            .render_function("module.renderPage")
313            .build()
314            .unwrap();
315        assert_eq!(config.render_function, "module.renderPage");
316    }
317}