1use 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#[derive(Debug)]
18struct ManifestEntry {
19 file: String,
20 css: Vec<String>,
21}
22
23#[derive(Debug, Clone)]
25struct HtmlTemplate {
26 content: String,
28}
29
30impl HtmlTemplate {
31 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 if let Some(manifest_path) = &config.assets_manifest_path {
55 let entry = Self::parse_manifest(manifest_path)?;
56
57 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 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 content = content.replace("<!--ssr:head-->", "");
80
81 Ok(Some(HtmlTemplate { content }))
82 }
83
84 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 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 fn inject(&self, fragment: &str) -> String {
126 self.content.replace("<!--ssr:outlet-->", fragment)
127 }
128}
129
130pub struct SsrEngine {
134 config: SsrConfig,
135
136 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 pub fn builder() -> SsrConfigBuilder {
160 SsrConfigBuilder::default()
161 }
162
163 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 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 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 #[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 #[cfg(all(feature = "v8-pool", feature = "cache"))]
232 pub async fn render_with_data(&self, url: &str, data: &str) -> SsrResult<Arc<str>> {
233 if let Some(cached) = self.cache.try_get(url) {
235 tracing::debug!("Cache hit: {}", url);
236 return Ok(cached);
237 }
238
239 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 self.cache.insert(url, Arc::clone(&html));
252
253 Ok(html)
254 }
255
256 #[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 #[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 #[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 pub fn has_template(&self) -> bool {
316 self.template.is_some()
317 }
318
319 #[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 #[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 #[cfg(feature = "cache")]
342 pub fn invalidate(&self, url: &str) {
343 self.cache.invalidate(url);
344 tracing::debug!("Cache invalidated: {}", url);
345 }
346
347 #[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 #[cfg(feature = "cache")]
360 pub fn clear_cache(&self) {
361 self.cache.clear();
362 tracing::info!("SSR cache cleared");
363 }
364
365 #[cfg(feature = "cache")]
367 pub fn cache_metrics(&self) -> crate::cache::CacheMetrics {
368 self.cache.metrics()
369 }
370
371 #[cfg(feature = "v8-pool")]
373 pub fn worker_count(&self) -> usize {
374 self.v8_pool.worker_count()
375 }
376
377 pub fn config(&self) -> &SsrConfig {
379 &self.config
380 }
381
382 #[cfg(feature = "cache")]
384 pub fn cache(&self) -> &SsrCache {
385 &self.cache
386 }
387
388 #[cfg(feature = "v8-pool")]
390 pub fn v8_pool(&self) -> &V8Pool {
391 &self.v8_pool
392 }
393}
394
395impl SsrConfigBuilder {
397 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}