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}