use forma_ir::parser::IrModule;
use rust_embed::Embed;
use std::collections::HashMap;
use crate::{AssetManifest, RenderMode};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IrLoadError {
pub route: String,
pub ir_file: String,
pub reason: String,
}
impl std::fmt::Display for IrLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"route {}: IR file {}: {}",
self.route, self.ir_file, self.reason
)
}
}
impl std::error::Error for IrLoadError {}
fn load_ir_modules_inner<A: Embed>(
manifest: &AssetManifest,
) -> (
HashMap<String, RenderMode>,
HashMap<String, IrModule>,
Vec<IrLoadError>,
) {
let mut render_modes = HashMap::new();
let mut ir_modules = HashMap::new();
let mut errors = Vec::new();
for (route_pattern, route_assets) in &manifest.routes {
let Some(ref ir_filename) = route_assets.ir else {
continue;
};
let Some(ir_bytes) = crate::assets::asset_bytes::<A>(ir_filename) else {
tracing::warn!(
route = %route_pattern,
ir_file = %ir_filename,
"Manifest declares an IR file that is not embedded — Phase 1 fallback"
);
errors.push(IrLoadError {
route: route_pattern.clone(),
ir_file: ir_filename.clone(),
reason: "declared in the manifest but not embedded".to_string(),
});
render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
continue;
};
match IrModule::parse(&ir_bytes) {
Ok(module) => {
tracing::info!(
route = %route_pattern,
ir_file = %ir_filename,
"Loaded IR module for SSR"
);
render_modes.insert(route_pattern.clone(), RenderMode::Phase2SsrReconcile);
ir_modules.insert(route_pattern.clone(), module);
}
Err(e) => {
tracing::warn!(
route = %route_pattern,
ir_file = %ir_filename,
error = %e,
"Failed to parse IR module — Phase 1 fallback"
);
errors.push(IrLoadError {
route: route_pattern.clone(),
ir_file: ir_filename.clone(),
reason: e.to_string(),
});
render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
}
}
}
if !ir_modules.is_empty() {
tracing::info!(
ssr_routes = ir_modules.len(),
"Phase 2 SSR enabled for {} route(s)",
ir_modules.len()
);
}
(render_modes, ir_modules, errors)
}
pub fn load_ir_modules<A: Embed>(
manifest: &AssetManifest,
) -> (HashMap<String, RenderMode>, HashMap<String, IrModule>) {
let (render_modes, ir_modules, _errors) = load_ir_modules_inner::<A>(manifest);
(render_modes, ir_modules)
}
#[allow(clippy::type_complexity)]
pub fn load_ir_modules_strict<A: Embed>(
manifest: &AssetManifest,
) -> Result<(HashMap<String, RenderMode>, HashMap<String, IrModule>), Vec<IrLoadError>> {
let (render_modes, ir_modules, errors) = load_ir_modules_inner::<A>(manifest);
if errors.is_empty() {
Ok((render_modes, ir_modules))
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::RouteAssets;
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "test_fixtures/"]
struct Fixtures;
fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
let mut routes = HashMap::new();
routes.insert(
route.to_string(),
RouteAssets {
js: vec![],
css: vec![],
fonts: vec![],
total_size_br: 0,
budget_warn_threshold: 0,
ir: ir.map(str::to_string),
},
);
AssetManifest {
version: 1,
build_hash: "test".to_string(),
assets: HashMap::new(),
routes,
wasm: None,
}
}
#[test]
fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
let errors = load_ir_modules_strict::<Fixtures>(&manifest).unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].route, "/");
assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
let (render_modes, ir_modules) = load_ir_modules::<Fixtures>(&manifest);
assert_eq!(render_modes.get("/"), Some(&RenderMode::Phase1ClientMount));
assert!(ir_modules.is_empty());
}
#[test]
fn a_route_with_no_ir_field_is_not_an_error() {
let manifest = manifest_with_ir("/", None);
let (render_modes, ir_modules) =
load_ir_modules_strict::<Fixtures>(&manifest).expect("must not error");
assert!(render_modes.is_empty());
assert!(ir_modules.is_empty());
}
}