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