use std::sync::Arc;
use crate::config::{SsrConfig, SsrConfigBuilder};
use crate::error::{SsrError, SsrResult};
#[cfg(feature = "v8-pool")]
use crate::v8_pool::{PoolError, V8Pool};
#[cfg(feature = "cache")]
use crate::cache::SsrCache;
#[derive(Debug)]
struct ManifestEntry {
file: String,
css: Vec<String>,
}
#[derive(Debug, Clone)]
struct HtmlTemplate {
content: String,
}
impl HtmlTemplate {
fn load(config: &SsrConfig) -> SsrResult<Option<Self>> {
let template_path = match &config.html_template_path {
Some(p) => p,
None => return Ok(None),
};
tracing::info!("📄 Loading HTML template from {:?}", template_path);
let mut content = std::fs::read_to_string(template_path).map_err(|e| {
SsrError::Template(format!(
"Failed to read HTML template {:?}: {}",
template_path, e
))
})?;
if !content.contains("<!--ssr:outlet-->") {
return Err(SsrError::Template(
"HTML template must contain <!--ssr:outlet--> placeholder".into(),
));
}
if let Some(manifest_path) = &config.assets_manifest_path {
let entry = Self::parse_manifest(manifest_path)?;
let css_tags: String = entry
.css
.iter()
.map(|p| format!(r#"<link rel="stylesheet" href="/{}" />"#, p))
.collect::<Vec<_>>()
.join("\n ");
let script_tag = format!(r#"<script type="module" src="/{}"></script>"#, entry.file);
content = content.replace("<!--ssr:css-->", &css_tags);
content = content.replace("<!--ssr:scripts-->", &script_tag);
tracing::info!(
"✅ Manifest parsed: JS={}, CSS files={}",
entry.file,
entry.css.len()
);
}
content = content.replace("<!--ssr:head-->", "");
Ok(Some(HtmlTemplate { content }))
}
fn parse_manifest(path: &std::path::Path) -> SsrResult<ManifestEntry> {
let raw = std::fs::read_to_string(path).map_err(|e| {
SsrError::Template(format!("Failed to read manifest {:?}: {}", path, e))
})?;
let manifest: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
SsrError::Template(format!("Failed to parse manifest JSON: {}", e))
})?;
let obj = manifest
.as_object()
.ok_or_else(|| SsrError::Template("Manifest is not a JSON object".into()))?;
let entry_value = obj
.values()
.find(|v| v.get("isEntry").and_then(|e| e.as_bool()).unwrap_or(false))
.or_else(|| obj.values().next())
.ok_or_else(|| SsrError::Template("Manifest has no entries".into()))?;
let file = entry_value
.get("file")
.and_then(|f| f.as_str())
.ok_or_else(|| SsrError::Template("Manifest entry missing 'file' field".into()))?
.to_string();
let css = entry_value
.get("css")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok(ManifestEntry { file, css })
}
fn inject(&self, fragment: &str) -> String {
self.content.replace("<!--ssr:outlet-->", fragment)
}
}
pub struct SsrEngine {
config: SsrConfig,
template: Option<HtmlTemplate>,
#[cfg(feature = "v8-pool")]
v8_pool: V8Pool,
#[cfg(feature = "cache")]
cache: SsrCache,
}
impl SsrEngine {
pub fn builder() -> SsrConfigBuilder {
SsrConfigBuilder::default()
}
pub fn new(config: SsrConfig) -> SsrResult<Self> {
tracing::info!(
"🚀 Initializing Rusty SSR engine (pool_size={}, cache_size={})",
config.pool_size,
config.cache_size
);
let template = HtmlTemplate::load(&config)?;
if template.is_some() {
tracing::info!("📄 HTML template system enabled");
}
#[cfg(feature = "v8-pool")]
let v8_pool = {
crate::v8_pool::init_bundle(&config.bundle_path)?;
V8Pool::new(crate::v8_pool::V8PoolConfig {
num_threads: config.pool_size,
queue_capacity: config.queue_capacity,
pin_threads: config.pin_threads,
request_timeout: config.request_timeout,
render_function: config.render_function.clone(),
})
};
#[cfg(feature = "cache")]
let cache = {
let ttl_secs = config.cache_ttl.map(|d| d.as_secs()).unwrap_or(0);
SsrCache::with_ttl(config.cache_size, ttl_secs)
};
Ok(Self {
config,
template,
#[cfg(feature = "v8-pool")]
v8_pool,
#[cfg(feature = "cache")]
cache,
})
}
#[cfg(all(feature = "v8-pool", feature = "cache"))]
pub async fn render(&self, url: &str) -> SsrResult<Arc<str>> {
self.render_with_data(url, "{}").await
}
#[cfg(all(feature = "v8-pool", feature = "cache"))]
pub async fn render_with_data(&self, url: &str, data: &str) -> SsrResult<Arc<str>> {
if let Some(cached) = self.cache.try_get(url) {
tracing::debug!("Cache hit: {}", url);
return Ok(cached);
}
tracing::debug!("Cache miss, rendering: {}", url);
let html = self
.v8_pool
.render_with_data(url.to_string(), data.to_string())
.await
.map_err(Self::map_pool_error)?;
let html: Arc<str> = Arc::from(html.as_str());
self.cache.insert(url, Arc::clone(&html));
Ok(html)
}
#[cfg(all(feature = "v8-pool", feature = "cache"))]
pub async fn render_json(
&self,
url: &str,
data: serde_json::Value,
) -> SsrResult<Arc<str>> {
let data_str = data.to_string();
self.render_with_data(url, &data_str).await
}
#[cfg(all(feature = "v8-pool", feature = "cache"))]
pub async fn render_to_html(&self, url: &str) -> SsrResult<String> {
self.render_to_html_with_data(url, "{}").await
}
#[cfg(all(feature = "v8-pool", feature = "cache"))]
pub async fn render_to_html_with_data(&self, url: &str, data: &str) -> SsrResult<String> {
let fragment = self.render_with_data(url, data).await?;
match &self.template {
Some(tmpl) => Ok(tmpl.inject(&fragment)),
None => Ok(fragment.to_string()),
}
}
pub fn has_template(&self) -> bool {
self.template.is_some()
}
#[cfg(feature = "v8-pool")]
pub async fn render_uncached(&self, url: &str, data: &str) -> SsrResult<String> {
self.v8_pool
.render_with_data(url.to_string(), data.to_string())
.await
.map_err(Self::map_pool_error)
}
#[cfg(feature = "v8-pool")]
pub async fn render_uncached_json(
&self,
url: &str,
data: serde_json::Value,
) -> SsrResult<String> {
self.render_uncached(url, &data.to_string()).await
}
#[cfg(feature = "cache")]
pub fn invalidate(&self, url: &str) {
self.cache.invalidate(url);
tracing::debug!("Cache invalidated: {}", url);
}
#[cfg(feature = "cache")]
pub fn invalidate_prefix(&self, prefix: &str) -> usize {
let removed = self.cache.invalidate_prefix(prefix);
tracing::info!("Cache invalidated {} entries with prefix: {}", removed, prefix);
removed
}
#[cfg(feature = "cache")]
pub fn clear_cache(&self) {
self.cache.clear();
tracing::info!("SSR cache cleared");
}
#[cfg(feature = "cache")]
pub fn cache_metrics(&self) -> crate::cache::CacheMetrics {
self.cache.metrics()
}
#[cfg(feature = "v8-pool")]
pub fn worker_count(&self) -> usize {
self.v8_pool.worker_count()
}
pub fn config(&self) -> &SsrConfig {
&self.config
}
#[cfg(feature = "cache")]
pub fn cache(&self) -> &SsrCache {
&self.cache
}
#[cfg(feature = "v8-pool")]
pub fn v8_pool(&self) -> &V8Pool {
&self.v8_pool
}
}
impl SsrConfigBuilder {
pub fn build_engine(self) -> SsrResult<SsrEngine> {
SsrEngine::new(self.build()?)
}
}
impl SsrEngine {
#[cfg(feature = "v8-pool")]
fn map_pool_error(err: PoolError) -> SsrError {
match err {
PoolError::Timeout => SsrError::Timeout,
PoolError::Disconnected => SsrError::PoolFull,
PoolError::WorkerCrashed => {
SsrError::JsExecution("V8 worker crashed".to_string())
}
PoolError::Render(msg) => SsrError::JsExecution(msg),
}
}
}