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}