dinja_core/
service.rs

1//! High-level rendering service with pooling, resource limits, and batch reporting.
2//!
3//! This module provides the main entry point for MDX rendering operations. It manages
4//! a pool of JavaScript renderers, enforces resource limits, and provides batch processing
5//! capabilities.
6//!
7//! ## Module Size Note
8//!
9//! This module is currently ~593 lines. While slightly over the ~500 line guideline,
10//! the code is well-organized into cohesive sections (configuration, service, errors, outcomes).
11//! Consider splitting into submodules if it grows beyond ~700 lines or if new major features
12//! are added that don't fit the current structure.
13//!
14//! ## Architecture
15//!
16//! The `RenderService` coordinates between several components:
17//! - **Renderer Pool**: Thread-local cache of initialized JavaScript runtimes
18//! - **Resource Limits**: Prevents memory exhaustion from large batches or content
19//! - **Batch Processing**: Renders multiple MDX files in a single operation
20//!
21//! ## Thread Safety
22//!
23//! `RenderService` is `Clone` and can be shared across threads. However, the underlying
24//! renderer pool uses thread-local storage, so each thread maintains its own cache of renderers.
25//!
26//! ## Configuration
27//!
28//! Configuration can be loaded from environment variables or provided programmatically.
29//! Use `RenderServiceConfig::from_env()` for environment-based configuration or construct
30//! `RenderServiceConfig` directly for programmatic configuration.
31//!
32//! ## Example
33//!
34//! ```no_run
35//! use dinja_core::service::{RenderService, RenderServiceConfig};
36//! use dinja_core::models::{NamedMdxBatchInput, RenderSettings, OutputFormat, RenderEngine};
37//! use std::collections::HashMap;
38//!
39//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
40//! let config = RenderServiceConfig::from_env();
41//! let service = RenderService::new(config)?;
42//!
43//! let mut mdx_files = HashMap::new();
44//! mdx_files.insert("page1.mdx".to_string(), "# Hello World".to_string());
45//!
46//! let input = NamedMdxBatchInput {
47//!     settings: RenderSettings {
48//!         output: OutputFormat::Html,
49//!         minify: true,
50//!         engine: RenderEngine::Base,
51//!         components: Vec::new(),
52//!     },
53//!     mdx: mdx_files,
54//!     components: None,
55//! };
56//!
57//! let outcome = service.render_batch(&input)?;
58//! # Ok(())
59//! # }
60//! ```
61use crate::mdx::{create_error_response, mdx_to_html_with_frontmatter};
62use crate::models::{
63    ComponentDefinition, NamedMdxBatchInput, OutputFormat, RenderEngine, RenderedMdx,
64    ResourceLimits,
65};
66use crate::renderer::pool::{RendererPool, RendererProfile};
67use anyhow::Error as AnyhowError;
68use serde::{Deserialize, Serialize};
69use std::collections::HashMap;
70use std::env;
71#[cfg(feature = "http")]
72use std::fs;
73#[cfg(feature = "http")]
74use std::path::Path;
75use std::path::PathBuf;
76
77const ENV_STATIC_DIR: &str = "RUST_CMS_STATIC_DIR";
78
79/// Configuration for the rendering service.
80#[derive(Clone, Debug)]
81pub struct RenderServiceConfig {
82    /// Directory containing static JavaScript files (e.g., engine.min.js)
83    pub static_dir: PathBuf,
84    /// Maximum number of cached renderers per profile
85    pub max_cached_renderers: usize,
86    /// Resource limits for preventing resource exhaustion
87    pub resource_limits: ResourceLimits,
88}
89
90impl Default for RenderServiceConfig {
91    fn default() -> Self {
92        Self {
93            static_dir: PathBuf::from("static"),
94            max_cached_renderers: 4,
95            resource_limits: ResourceLimits::default(),
96        }
97    }
98}
99
100/// TOML configuration structure for file-based configuration
101#[cfg(feature = "http")]
102#[derive(Deserialize, Debug)]
103struct TomlConfig {
104    static_dir: Option<String>,
105    max_cached_renderers: Option<usize>,
106    resource_limits: Option<TomlResourceLimits>,
107}
108
109#[cfg(feature = "http")]
110#[derive(Deserialize, Debug)]
111struct TomlResourceLimits {
112    max_batch_size: Option<usize>,
113    max_mdx_content_size: Option<usize>,
114    max_component_code_size: Option<usize>,
115}
116
117impl RenderServiceConfig {
118    /// Loads configuration from environment variables, falling back to defaults.
119    pub fn from_env() -> Self {
120        let mut config = Self::default();
121        if let Ok(path) = env::var(ENV_STATIC_DIR) {
122            config.static_dir = PathBuf::from(path);
123        }
124        config
125    }
126
127    /// Loads configuration from a TOML file.
128    ///
129    /// # Arguments
130    /// * `path` - Path to the TOML configuration file
131    ///
132    /// # Returns
133    /// `Ok(RenderServiceConfig)` if the file was successfully loaded and parsed,
134    /// `Err` with a descriptive error message if the file cannot be read or parsed.
135    ///
136    /// # Note
137    /// This method requires the `http` feature to be enabled.
138    #[cfg(feature = "http")]
139    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
140        let path = path.as_ref();
141        let contents = fs::read_to_string(path).map_err(|e| {
142            format!(
143                "Failed to read configuration file {}: {}",
144                path.display(),
145                e
146            )
147        })?;
148
149        let toml_config: TomlConfig = toml::from_str(&contents).map_err(|e| {
150            format!(
151                "Failed to parse TOML configuration file {}: {}",
152                path.display(),
153                e
154            )
155        })?;
156
157        let mut config = Self::default();
158
159        if let Some(static_dir) = toml_config.static_dir {
160            config.static_dir = PathBuf::from(static_dir);
161        }
162
163        if let Some(max_cached) = toml_config.max_cached_renderers {
164            config.max_cached_renderers = max_cached;
165        }
166
167        if let Some(limits) = toml_config.resource_limits {
168            if let Some(max_batch_size) = limits.max_batch_size {
169                config.resource_limits.max_batch_size = max_batch_size;
170            }
171            if let Some(max_mdx_content_size) = limits.max_mdx_content_size {
172                config.resource_limits.max_mdx_content_size = max_mdx_content_size;
173            }
174            if let Some(max_component_code_size) = limits.max_component_code_size {
175                config.resource_limits.max_component_code_size = max_component_code_size;
176            }
177        }
178
179        Ok(config)
180    }
181
182    /// Loads configuration from a TOML file and merges with environment variables.
183    ///
184    /// Environment variables override file settings. This allows for deployment-specific
185    /// overrides while maintaining a base configuration in a file.
186    ///
187    /// # Arguments
188    /// * `path` - Path to the TOML configuration file
189    ///
190    /// # Returns
191    /// `Ok(RenderServiceConfig)` if the file was successfully loaded and parsed,
192    /// `Err` with a descriptive error message if the file cannot be read or parsed.
193    ///
194    /// # Note
195    /// This method requires the `http` feature to be enabled.
196    #[cfg(feature = "http")]
197    pub fn from_file_and_env(path: impl AsRef<Path>) -> Result<Self, String> {
198        let mut config = Self::from_file(path)?;
199
200        // Environment variables override file settings
201        if let Ok(path) = env::var(ENV_STATIC_DIR) {
202            config.static_dir = PathBuf::from(path);
203        }
204
205        Ok(config)
206    }
207
208    /// Validates the configuration and returns an error if invalid.
209    ///
210    /// # Returns
211    /// `Ok(())` if configuration is valid, `Err` with a descriptive message if invalid.
212    pub fn validate(&self) -> Result<(), String> {
213        // Validate static directory exists
214        if !self.static_dir.exists() {
215            return Err(format!(
216                "Static directory does not exist: {}",
217                self.static_dir.display()
218            ));
219        }
220        if !self.static_dir.is_dir() {
221            return Err(format!(
222                "Static directory path is not a directory: {}",
223                self.static_dir.display()
224            ));
225        }
226
227        // Validate max_cached_renderers is reasonable
228        if self.max_cached_renderers == 0 {
229            return Err("max_cached_renderers must be greater than 0".to_string());
230        }
231        if self.max_cached_renderers > 1000 {
232            return Err(format!(
233                "max_cached_renderers ({}) is unreasonably large, maximum recommended is 1000",
234                self.max_cached_renderers
235            ));
236        }
237
238        // Validate resource limits
239        self.resource_limits.validate()?;
240
241        Ok(())
242    }
243}
244
245/// Top-level service that batches MDX rendering requests.
246///
247/// This service manages a pool of JavaScript renderers and provides batch rendering
248/// capabilities for MDX content. It handles resource limits, error recovery, and
249/// renderer lifecycle management.
250#[derive(Clone)]
251pub struct RenderService {
252    config: RenderServiceConfig,
253    pool: RendererPool,
254}
255
256impl RenderService {
257    /// Creates a new render service with the given configuration.
258    ///
259    /// Configuration is always validated, even in release builds.
260    ///
261    /// # Arguments
262    /// * `config` - Service configuration including static directory and resource limits
263    ///
264    /// # Returns
265    /// `Ok(RenderService)` if configuration is valid, `Err` with validation error if invalid
266    pub fn new(config: RenderServiceConfig) -> Result<Self, String> {
267        config.validate()?;
268        let pool = RendererPool::new(config.static_dir.clone(), config.max_cached_renderers);
269        // Warm up the pool with one renderer per common profile to reduce first-request latency
270        // Skip warming when RUST_CMS_SKIP_POOL_WARMING is set (useful for tests)
271        if env::var("RUST_CMS_SKIP_POOL_WARMING").is_err() {
272            pool.warm(1);
273        }
274        Ok(Self { config, pool })
275    }
276
277    /// Creates a new render service with configuration validation.
278    ///
279    /// This is an alias for `new()` which always validates configuration.
280    /// Kept for backward compatibility.
281    ///
282    /// # Arguments
283    /// * `config` - Service configuration including static directory and resource limits
284    ///
285    /// # Returns
286    /// `Ok(RenderService)` if configuration is valid, `Err` with validation error if invalid
287    pub fn new_with_validation(config: RenderServiceConfig) -> Result<Self, String> {
288        Self::new(config)
289    }
290
291    /// Returns a reference to the service configuration.
292    pub fn config(&self) -> &RenderServiceConfig {
293        &self.config
294    }
295
296    /// Returns a reference to the renderer pool.
297    ///
298    /// This is primarily useful for testing and advanced use cases.
299    pub fn pool(&self) -> &RendererPool {
300        &self.pool
301    }
302
303    /// Renders a batch of MDX files.
304    ///
305    /// ## Error Recovery Strategy
306    ///
307    /// This function implements graceful error recovery: individual file failures don't stop
308    /// the batch processing. Errors are collected and returned in the `BatchRenderOutcome`,
309    /// allowing partial success scenarios. This is important for batch operations where
310    /// some files may be invalid while others are valid.
311    ///
312    /// ## Public API Error Type Consistency
313    ///
314    /// This function returns `Result<BatchRenderOutcome, RenderBatchError>`, which is consistent
315    /// with the service boundary pattern:
316    /// - Domain functions return `Result<T, MdxError>` (domain-specific errors)
317    /// - Service functions return `Result<T, RenderBatchError>` (service-level errors)
318    /// - HTTP handlers convert to `anyhow::Error` (framework-level errors)
319    ///
320    /// # Arguments
321    /// * `input` - Batch input containing MDX content, components, and settings
322    ///
323    /// # Returns
324    /// A `BatchRenderOutcome` containing rendered files and any errors
325    ///
326    /// # Errors
327    /// Returns `RenderBatchError` if resource limits are exceeded, custom engines are
328    /// disabled but requested, or internal errors occur during rendering.
329    pub fn render_batch(
330        &self,
331        input: &NamedMdxBatchInput,
332    ) -> Result<BatchRenderOutcome, RenderBatchError> {
333        let components_context = self.resolve_components(input)?;
334        let resolved_components = components_context.as_ref();
335
336        // Validate resource limits
337        self.validate_resource_limits(input, resolved_components)?;
338
339        let profile = self.profile_for_request(&input.settings.output)?;
340
341        if input.mdx.is_empty() {
342            return Ok(BatchRenderOutcome::empty());
343        }
344
345        let renderer = self
346            .pool
347            .checkout(profile)
348            .map_err(RenderBatchError::Internal)?;
349
350        let mut files = HashMap::with_capacity(input.mdx.len());
351        // Pre-allocate errors Vec with estimated capacity (assume ~10% failure rate)
352        // This denominator represents the expected success rate: 1/10 = 10% failure rate
353        // Pre-allocating prevents multiple reallocations during batch processing
354        const ESTIMATED_ERROR_RATE_DENOMINATOR: usize = 10;
355        let mut errors = Vec::with_capacity(input.mdx.len() / ESTIMATED_ERROR_RATE_DENOMINATOR);
356        let mut succeeded = 0usize;
357        let mut failed = 0usize;
358
359        // HOT PATH: Batch processing loop - processes multiple MDX files sequentially
360        // Error recovery: Individual file failures don't stop the batch; errors are collected
361        // and returned in the outcome. This allows partial success scenarios.
362        for (name, mdx_source) in &input.mdx {
363            let renderer_ref = renderer
364                .renderer()
365                .map_err(|e| RenderBatchError::Internal(anyhow::Error::from(e)))?;
366            match mdx_to_html_with_frontmatter(
367                mdx_source,
368                renderer_ref,
369                resolved_components,
370                &input.settings,
371            ) {
372                Ok(rendered) => {
373                    succeeded += 1;
374                    files.insert(name.clone(), FileRenderOutcome::success(rendered));
375                }
376                Err(err) => {
377                    failed += 1;
378                    // Convert MdxError to anyhow::Error for error response creation
379                    // Using `anyhow::Error::from()` preserves the error chain automatically
380                    // since MdxError implements std::error::Error via thiserror
381                    let anyhow_err = anyhow::Error::from(err);
382                    // Preserve full error context including chain using {:#} format
383                    // This includes all underlying causes in the error chain
384                    let message = format!("{:#}", anyhow_err);
385                    let fallback = create_error_response(&anyhow_err);
386                    errors.push(BatchError {
387                        file: name.clone(),
388                        message: message.clone(),
389                    });
390                    files.insert(name.clone(), FileRenderOutcome::failure(message, fallback));
391                }
392            }
393        }
394
395        Ok(BatchRenderOutcome::new(files, errors, succeeded, failed))
396    }
397
398    fn validate_resource_limits(
399        &self,
400        input: &NamedMdxBatchInput,
401        components: Option<&HashMap<String, ComponentDefinition>>,
402    ) -> Result<(), RenderBatchError> {
403        let limits = &self.config.resource_limits;
404
405        // Check batch size
406        if input.mdx.len() > limits.max_batch_size {
407            return Err(RenderBatchError::InvalidRequest(format!(
408                "Batch size {} exceeds maximum allowed {}",
409                input.mdx.len(),
410                limits.max_batch_size
411            )));
412        }
413
414        // Check MDX content sizes
415        for (name, content) in &input.mdx {
416            if content.len() > limits.max_mdx_content_size {
417                return Err(RenderBatchError::InvalidRequest(format!(
418                    "MDX content for '{}' is {} bytes, exceeds maximum allowed {} bytes",
419                    name,
420                    content.len(),
421                    limits.max_mdx_content_size
422                )));
423            }
424        }
425
426        // Check component code sizes
427        if let Some(component_map) = components {
428            for (name, comp_def) in component_map {
429                if comp_def.code.len() > limits.max_component_code_size {
430                    return Err(RenderBatchError::InvalidRequest(format!(
431                        "Component '{}' code is {} bytes, exceeds maximum allowed {} bytes",
432                        name,
433                        comp_def.code.len(),
434                        limits.max_component_code_size
435                    )));
436                }
437            }
438        }
439
440        Ok(())
441    }
442
443    fn profile_for_request(
444        &self,
445        format: &OutputFormat,
446    ) -> Result<RendererProfile, RenderBatchError> {
447        match format {
448            OutputFormat::Html | OutputFormat::Javascript | OutputFormat::Schema => {
449                Ok(RendererProfile::Engine)
450            }
451        }
452    }
453
454    fn resolve_components<'a>(
455        &self,
456        input: &'a NamedMdxBatchInput,
457    ) -> Result<ComponentsContext<'a>, RenderBatchError> {
458        match input.settings.engine {
459            RenderEngine::Base => {
460                let generated = build_base_components(&input.settings.components);
461                if generated.is_empty() {
462                    Ok(ComponentsContext::Borrowed(None))
463                } else {
464                    Ok(ComponentsContext::Owned(generated))
465                }
466            }
467            RenderEngine::Custom => Ok(ComponentsContext::Borrowed(input.components.as_ref())),
468        }
469    }
470}
471
472/// Provides shared access to either borrowed or owned component definitions.
473enum ComponentsContext<'a> {
474    Borrowed(Option<&'a HashMap<String, ComponentDefinition>>),
475    Owned(HashMap<String, ComponentDefinition>),
476}
477
478impl<'a> ComponentsContext<'a> {
479    fn as_ref(&self) -> Option<&HashMap<String, ComponentDefinition>> {
480        match self {
481            ComponentsContext::Borrowed(maybe) => *maybe,
482            ComponentsContext::Owned(map) => Some(map),
483        }
484    }
485}
486
487const BASE_COMPONENT_TEMPLATE: &str =
488    "function Component(props) {\n    return <Base {...props} />;\n}\n";
489
490fn build_base_components(component_names: &[String]) -> HashMap<String, ComponentDefinition> {
491    let mut components = HashMap::with_capacity(component_names.len());
492    for name in component_names {
493        let trimmed = name.trim();
494        if trimmed.is_empty() {
495            continue;
496        }
497        let normalized = trimmed.to_string();
498        let definition = ComponentDefinition {
499            name: Some(normalized.clone()),
500            docs: None,
501            args: None,
502            code: String::from(BASE_COMPONENT_TEMPLATE),
503        };
504        components.insert(normalized, definition);
505    }
506    components
507}
508
509/// Errors surfaced by the batch renderer.
510#[derive(Debug)]
511pub enum RenderBatchError {
512    /// Request is forbidden (e.g., custom engines disabled)
513    Forbidden(String),
514    /// Request is invalid (e.g., resource limits exceeded)
515    InvalidRequest(String),
516    /// Internal error during rendering
517    Internal(AnyhowError),
518}
519
520impl std::error::Error for RenderBatchError {
521    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
522        match self {
523            RenderBatchError::Internal(err) => Some(err.as_ref()),
524            _ => None,
525        }
526    }
527}
528
529impl std::fmt::Display for RenderBatchError {
530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531        match self {
532            RenderBatchError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
533            RenderBatchError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
534            RenderBatchError::Internal(err) => write!(f, "Internal error: {}", err),
535        }
536    }
537}
538
539impl From<anyhow::Error> for RenderBatchError {
540    fn from(err: anyhow::Error) -> Self {
541        RenderBatchError::Internal(err)
542    }
543}
544
545impl From<crate::error::MdxError> for RenderBatchError {
546    fn from(err: crate::error::MdxError) -> Self {
547        RenderBatchError::Internal(anyhow::Error::from(err))
548    }
549}
550
551/// Outcome of a batch rendering operation
552#[derive(Debug, Serialize, Deserialize)]
553pub struct BatchRenderOutcome {
554    /// Total number of files processed
555    pub total: usize,
556    /// Number of files that rendered successfully
557    pub succeeded: usize,
558    /// Number of files that failed to render
559    pub failed: usize,
560    /// List of errors encountered during rendering
561    #[serde(default, skip_serializing_if = "Vec::is_empty")]
562    pub errors: Vec<BatchError>,
563    /// Map of file names to their rendering outcomes
564    #[serde(default)]
565    pub files: HashMap<String, FileRenderOutcome>,
566}
567
568impl BatchRenderOutcome {
569    /// Creates a new batch render outcome
570    ///
571    /// # Arguments
572    /// * `files` - Map of file names to their render outcomes
573    /// * `errors` - List of errors encountered
574    /// * `succeeded` - Number of successful renders
575    /// * `failed` - Number of failed renders
576    pub fn new(
577        files: HashMap<String, FileRenderOutcome>,
578        errors: Vec<BatchError>,
579        succeeded: usize,
580        failed: usize,
581    ) -> Self {
582        let total = succeeded + failed;
583        Self {
584            total,
585            succeeded,
586            failed,
587            errors,
588            files,
589        }
590    }
591
592    /// Creates an empty batch render outcome (no files processed)
593    pub fn empty() -> Self {
594        Self {
595            total: 0,
596            succeeded: 0,
597            failed: 0,
598            errors: Vec::new(),
599            files: HashMap::new(),
600        }
601    }
602
603    /// Returns true if all files rendered successfully
604    pub fn is_all_success(&self) -> bool {
605        self.failed == 0
606    }
607
608    /// Returns true if all files failed to render
609    pub fn is_complete_failure(&self) -> bool {
610        self.total > 0 && self.succeeded == 0
611    }
612}
613
614/// Error information for a single file in a batch
615#[derive(Debug, Serialize, Deserialize)]
616pub struct BatchError {
617    /// Name of the file that failed
618    pub file: String,
619    /// Error message describing the failure
620    pub message: String,
621}
622
623/// Status of a single file render operation
624#[derive(Debug, Serialize, Deserialize)]
625#[serde(rename_all = "snake_case")]
626pub enum FileRenderStatus {
627    /// File rendered successfully
628    Success,
629    /// File failed to render
630    Failed,
631}
632
633/// Outcome of rendering a single file
634#[derive(Debug, Serialize, Deserialize)]
635pub struct FileRenderOutcome {
636    /// Status of the render operation
637    pub status: FileRenderStatus,
638    /// Rendered result (present even on failure as fallback)
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub result: Option<RenderedMdx>,
641    /// Error message (only present on failure)
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub error: Option<String>,
644}
645
646impl FileRenderOutcome {
647    fn success(result: RenderedMdx) -> Self {
648        Self {
649            status: FileRenderStatus::Success,
650            result: Some(result),
651            error: None,
652        }
653    }
654
655    fn failure(message: String, fallback: RenderedMdx) -> Self {
656        Self {
657            status: FileRenderStatus::Failed,
658            result: Some(fallback),
659            error: Some(message),
660        }
661    }
662}