Skip to main content

rusty_ssr/
engine.rs

1//! Main SSR Engine
2
3use std::sync::Arc;
4
5use crate::config::{SsrConfig, SsrConfigBuilder};
6use crate::error::{SsrError, SsrResult};
7
8#[cfg(feature = "v8-pool")]
9use crate::v8_pool::{PoolError, V8Pool};
10
11#[cfg(feature = "cache")]
12use crate::cache::SsrCache;
13
14// ── HTML Template System ─────────────────────────────────────────────────────
15
16/// Parsed Vite manifest entry
17#[derive(Debug)]
18struct ManifestEntry {
19    file: String,
20    css: Vec<String>,
21}
22
23/// Pre-loaded HTML template with asset tags injected
24#[derive(Debug, Clone)]
25struct HtmlTemplate {
26    /// Template string with `<!--ssr:outlet-->` still present (replaced per-request)
27    content: String,
28}
29
30impl HtmlTemplate {
31    /// Load template from file, parse manifest, and inject asset tags
32    fn load(config: &SsrConfig) -> SsrResult<Option<Self>> {
33        let template_path = match &config.html_template_path {
34            Some(p) => p,
35            None => return Ok(None),
36        };
37
38        tracing::info!("📄 Loading HTML template from {:?}", template_path);
39
40        let mut content = std::fs::read_to_string(template_path).map_err(|e| {
41            SsrError::Template(format!(
42                "Failed to read HTML template {:?}: {}",
43                template_path, e
44            ))
45        })?;
46
47        if !content.contains("<!--ssr:outlet-->") {
48            return Err(SsrError::Template(
49                "HTML template must contain <!--ssr:outlet--> placeholder".into(),
50            ));
51        }
52
53        // Parse Vite manifest and inject asset tags
54        if let Some(manifest_path) = &config.assets_manifest_path {
55            let entry = Self::parse_manifest(manifest_path)?;
56
57            // Build CSS link tags
58            let css_tags: String = entry
59                .css
60                .iter()
61                .map(|p| format!(r#"<link rel="stylesheet" href="/{}" />"#, p))
62                .collect::<Vec<_>>()
63                .join("\n    ");
64
65            // Build script tag
66            let script_tag = format!(r#"<script type="module" src="/{}"></script>"#, entry.file);
67
68            content = content.replace("<!--ssr:css-->", &css_tags);
69            content = content.replace("<!--ssr:scripts-->", &script_tag);
70
71            tracing::info!(
72                "✅ Manifest parsed: JS={}, CSS files={}",
73                entry.file,
74                entry.css.len()
75            );
76        }
77
78        // Replace ssr:head with empty (reserved for future use)
79        content = content.replace("<!--ssr:head-->", "");
80
81        Ok(Some(HtmlTemplate { content }))
82    }
83
84    /// Parse Vite manifest.json and find the entry chunk
85    fn parse_manifest(path: &std::path::Path) -> SsrResult<ManifestEntry> {
86        let raw = std::fs::read_to_string(path).map_err(|e| {
87            SsrError::Template(format!("Failed to read manifest {:?}: {}", path, e))
88        })?;
89
90        let manifest: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
91            SsrError::Template(format!("Failed to parse manifest JSON: {}", e))
92        })?;
93
94        let obj = manifest
95            .as_object()
96            .ok_or_else(|| SsrError::Template("Manifest is not a JSON object".into()))?;
97
98        // Find entry with isEntry: true, or fall back to first entry
99        let entry_value = obj
100            .values()
101            .find(|v| v.get("isEntry").and_then(|e| e.as_bool()).unwrap_or(false))
102            .or_else(|| obj.values().next())
103            .ok_or_else(|| SsrError::Template("Manifest has no entries".into()))?;
104
105        let file = entry_value
106            .get("file")
107            .and_then(|f| f.as_str())
108            .ok_or_else(|| SsrError::Template("Manifest entry missing 'file' field".into()))?
109            .to_string();
110
111        let css = entry_value
112            .get("css")
113            .and_then(|c| c.as_array())
114            .map(|arr| {
115                arr.iter()
116                    .filter_map(|v| v.as_str().map(String::from))
117                    .collect()
118            })
119            .unwrap_or_default();
120
121        Ok(ManifestEntry { file, css })
122    }
123
124    /// Inject rendered HTML fragment into the template
125    fn inject(&self, fragment: &str) -> String {
126        self.content.replace("<!--ssr:outlet-->", fragment)
127    }
128}
129
130// ── SSR Engine ───────────────────────────────────────────────────────────────
131
132/// The main SSR engine that coordinates V8 pool and caching
133pub struct SsrEngine {
134    config: SsrConfig,
135
136    /// Pre-loaded HTML template (None = return raw fragments)
137    template: Option<HtmlTemplate>,
138
139    #[cfg(feature = "v8-pool")]
140    v8_pool: V8Pool,
141
142    #[cfg(feature = "cache")]
143    cache: SsrCache,
144}
145
146impl SsrEngine {
147    /// Create a new configuration builder
148    ///
149    /// # Example
150    /// ```rust,ignore
151    /// use rusty_ssr::SsrEngine;
152    ///
153    /// let engine = SsrEngine::builder()
154    ///     .bundle_path("ssr-bundle.js")
155    ///     .pool_size(4)
156    ///     .build_engine()
157    ///     .expect("Failed to create engine");
158    /// ```
159    pub fn builder() -> SsrConfigBuilder {
160        SsrConfigBuilder::default()
161    }
162
163    /// Create a new SSR engine with the given configuration
164    pub fn new(config: SsrConfig) -> SsrResult<Self> {
165        tracing::info!(
166            "🚀 Initializing Rusty SSR engine (pool_size={}, cache_size={})",
167            config.pool_size,
168            config.cache_size
169        );
170
171        // Load HTML template + manifest (if configured)
172        let template = HtmlTemplate::load(&config)?;
173        if template.is_some() {
174            tracing::info!("📄 HTML template system enabled");
175        }
176
177        #[cfg(feature = "v8-pool")]
178        let v8_pool = {
179            // Initialize the V8 bundle
180            crate::v8_pool::init_bundle(&config.bundle_path)?;
181
182            V8Pool::new(crate::v8_pool::V8PoolConfig {
183                num_threads: config.pool_size,
184                queue_capacity: config.queue_capacity,
185                pin_threads: config.pin_threads,
186                request_timeout: config.request_timeout,
187                render_function: config.render_function.clone(),
188            })
189        };
190
191        #[cfg(feature = "cache")]
192        let cache = {
193            let ttl_secs = config.cache_ttl.map(|d| d.as_secs()).unwrap_or(0);
194            SsrCache::with_ttl(config.cache_size, ttl_secs)
195        };
196
197        Ok(Self {
198            config,
199            template,
200            #[cfg(feature = "v8-pool")]
201            v8_pool,
202            #[cfg(feature = "cache")]
203            cache,
204        })
205    }
206
207    /// Render a URL to HTML
208    ///
209    /// This will first check the cache, and if not found, render via V8.
210    ///
211    /// # Arguments
212    /// * `url` - The URL path to render (e.g., "/home", "/products/123")
213    ///
214    /// # Example
215    /// ```rust,no_run
216    /// # use rusty_ssr::SsrEngine;
217    /// # async fn example(engine: SsrEngine) {
218    /// let html = engine.render("/home").await.unwrap();
219    /// # }
220    /// ```
221    #[cfg(all(feature = "v8-pool", feature = "cache"))]
222    pub async fn render(&self, url: &str) -> SsrResult<Arc<str>> {
223        self.render_with_data(url, "{}").await
224    }
225
226    /// Render a URL to HTML with custom data
227    ///
228    /// # Arguments
229    /// * `url` - The URL path to render
230    /// * `data` - JSON string with data to pass to the render function
231    #[cfg(all(feature = "v8-pool", feature = "cache"))]
232    pub async fn render_with_data(&self, url: &str, data: &str) -> SsrResult<Arc<str>> {
233        // Check cache first
234        if let Some(cached) = self.cache.try_get(url) {
235            tracing::debug!("Cache hit: {}", url);
236            return Ok(cached);
237        }
238
239        // Cache miss - render via V8
240        tracing::debug!("Cache miss, rendering: {}", url);
241
242        let html = self
243            .v8_pool
244            .render_with_data(url.to_string(), data.to_string())
245            .await
246            .map_err(Self::map_pool_error)?;
247
248        let html: Arc<str> = Arc::from(html.as_str());
249
250        // Store in cache
251        self.cache.insert(url, Arc::clone(&html));
252
253        Ok(html)
254    }
255
256    /// Render a URL with JSON data (serde_json::Value)
257    ///
258    /// Convenience method that serializes the Value to a string.
259    ///
260    /// # Example
261    /// ```rust,no_run
262    /// # use rusty_ssr::SsrEngine;
263    /// # async fn example(engine: SsrEngine) {
264    /// use serde_json::json;
265    ///
266    /// let data = json!({
267    ///     "user": { "name": "John" },
268    ///     "products": [1, 2, 3]
269    /// });
270    /// let html = engine.render_json("/products", data).await.unwrap();
271    /// # }
272    /// ```
273    #[cfg(all(feature = "v8-pool", feature = "cache"))]
274    pub async fn render_json(
275        &self,
276        url: &str,
277        data: serde_json::Value,
278    ) -> SsrResult<Arc<str>> {
279        let data_str = data.to_string();
280        self.render_with_data(url, &data_str).await
281    }
282
283    /// Render a URL and inject result into the HTML template
284    ///
285    /// If no template is configured, behaves identically to `render()`.
286    /// When a template is set, the V8-rendered fragment is injected into
287    /// `<!--ssr:outlet-->` and a complete HTML document is returned.
288    ///
289    /// # Example
290    /// ```rust,no_run
291    /// # use rusty_ssr::SsrEngine;
292    /// # async fn example(engine: SsrEngine) {
293    /// // Returns complete HTML document if template is configured
294    /// let html = engine.render_to_html("/home").await.unwrap();
295    /// // html = "<!doctype html><html>...<div id='root'>...app...</div>...</html>"
296    /// # }
297    /// ```
298    #[cfg(all(feature = "v8-pool", feature = "cache"))]
299    pub async fn render_to_html(&self, url: &str) -> SsrResult<String> {
300        self.render_to_html_with_data(url, "{}").await
301    }
302
303    /// Render a URL with data and inject into the HTML template
304    #[cfg(all(feature = "v8-pool", feature = "cache"))]
305    pub async fn render_to_html_with_data(&self, url: &str, data: &str) -> SsrResult<String> {
306        let fragment = self.render_with_data(url, data).await?;
307
308        match &self.template {
309            Some(tmpl) => Ok(tmpl.inject(&fragment)),
310            None => Ok(fragment.to_string()),
311        }
312    }
313
314    /// Check if the HTML template system is enabled
315    pub fn has_template(&self) -> bool {
316        self.template.is_some()
317    }
318
319    /// Render without caching (always hits V8)
320    #[cfg(feature = "v8-pool")]
321    pub async fn render_uncached(&self, url: &str, data: &str) -> SsrResult<String> {
322        self.v8_pool
323            .render_with_data(url.to_string(), data.to_string())
324            .await
325            .map_err(Self::map_pool_error)
326    }
327
328    /// Render without caching with JSON data
329    #[cfg(feature = "v8-pool")]
330    pub async fn render_uncached_json(
331        &self,
332        url: &str,
333        data: serde_json::Value,
334    ) -> SsrResult<String> {
335        self.render_uncached(url, &data.to_string()).await
336    }
337
338    /// Invalidate a single cached URL
339    ///
340    /// Use after content updates for a specific page.
341    #[cfg(feature = "cache")]
342    pub fn invalidate(&self, url: &str) {
343        self.cache.invalidate(url);
344        tracing::debug!("Cache invalidated: {}", url);
345    }
346
347    /// Invalidate all cached URLs matching a prefix
348    ///
349    /// Example: `engine.invalidate_prefix("/products")` clears all product pages.
350    /// Returns the number of removed entries.
351    #[cfg(feature = "cache")]
352    pub fn invalidate_prefix(&self, prefix: &str) -> usize {
353        let removed = self.cache.invalidate_prefix(prefix);
354        tracing::info!("Cache invalidated {} entries with prefix: {}", removed, prefix);
355        removed
356    }
357
358    /// Clear the SSR cache
359    #[cfg(feature = "cache")]
360    pub fn clear_cache(&self) {
361        self.cache.clear();
362        tracing::info!("SSR cache cleared");
363    }
364
365    /// Get cache metrics
366    #[cfg(feature = "cache")]
367    pub fn cache_metrics(&self) -> crate::cache::CacheMetrics {
368        self.cache.metrics()
369    }
370
371    /// Get the number of active V8 workers
372    #[cfg(feature = "v8-pool")]
373    pub fn worker_count(&self) -> usize {
374        self.v8_pool.worker_count()
375    }
376
377    /// Get a reference to the configuration
378    pub fn config(&self) -> &SsrConfig {
379        &self.config
380    }
381
382    /// Get a reference to the cache (if enabled)
383    #[cfg(feature = "cache")]
384    pub fn cache(&self) -> &SsrCache {
385        &self.cache
386    }
387
388    /// Get a reference to the V8 pool (if enabled)
389    #[cfg(feature = "v8-pool")]
390    pub fn v8_pool(&self) -> &V8Pool {
391        &self.v8_pool
392    }
393}
394
395/// Builder extension to create SsrEngine directly
396impl SsrConfigBuilder {
397    /// Build the configuration and create an SsrEngine
398    pub fn build_engine(self) -> SsrResult<SsrEngine> {
399        SsrEngine::new(self.build()?)
400    }
401}
402
403impl SsrEngine {
404    #[cfg(feature = "v8-pool")]
405    fn map_pool_error(err: PoolError) -> SsrError {
406        match err {
407            PoolError::Timeout => SsrError::Timeout,
408            PoolError::Disconnected => SsrError::PoolFull,
409            PoolError::WorkerCrashed => {
410                SsrError::JsExecution("V8 worker crashed".to_string())
411            }
412            PoolError::Render(msg) => SsrError::JsExecution(msg),
413        }
414    }
415}