forma_server/
ir_compat.rs1use forma_ir::parser::IrModule;
2use rust_embed::Embed;
3use std::collections::HashMap;
4
5use crate::{AssetManifest, RenderMode};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct IrLoadError {
10 pub route: String,
12 pub ir_file: String,
14 pub reason: String,
16}
17
18impl std::fmt::Display for IrLoadError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 write!(
21 f,
22 "route {}: IR file {}: {}",
23 self.route, self.ir_file, self.reason
24 )
25 }
26}
27
28impl std::error::Error for IrLoadError {}
29
30fn load_ir_modules_inner<A: Embed>(
41 manifest: &AssetManifest,
42) -> (
43 HashMap<String, RenderMode>,
44 HashMap<String, IrModule>,
45 Vec<IrLoadError>,
46) {
47 let mut render_modes = HashMap::new();
48 let mut ir_modules = HashMap::new();
49 let mut errors = Vec::new();
50
51 for (route_pattern, route_assets) in &manifest.routes {
52 let Some(ref ir_filename) = route_assets.ir else {
53 continue;
55 };
56
57 let Some(ir_bytes) = crate::assets::asset_bytes::<A>(ir_filename) else {
58 tracing::warn!(
59 route = %route_pattern,
60 ir_file = %ir_filename,
61 "Manifest declares an IR file that is not embedded — Phase 1 fallback"
62 );
63 errors.push(IrLoadError {
64 route: route_pattern.clone(),
65 ir_file: ir_filename.clone(),
66 reason: "declared in the manifest but not embedded".to_string(),
67 });
68 render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
71 continue;
72 };
73
74 match IrModule::parse(&ir_bytes) {
75 Ok(module) => {
76 tracing::info!(
77 route = %route_pattern,
78 ir_file = %ir_filename,
79 "Loaded IR module for SSR"
80 );
81 render_modes.insert(route_pattern.clone(), RenderMode::Phase2SsrReconcile);
82 ir_modules.insert(route_pattern.clone(), module);
83 }
84 Err(e) => {
85 tracing::warn!(
86 route = %route_pattern,
87 ir_file = %ir_filename,
88 error = %e,
89 "Failed to parse IR module — Phase 1 fallback"
90 );
91 errors.push(IrLoadError {
92 route: route_pattern.clone(),
93 ir_file: ir_filename.clone(),
94 reason: e.to_string(),
95 });
96 render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
97 }
98 }
99 }
100
101 if !ir_modules.is_empty() {
102 tracing::info!(
103 ssr_routes = ir_modules.len(),
104 "Phase 2 SSR enabled for {} route(s)",
105 ir_modules.len()
106 );
107 }
108
109 (render_modes, ir_modules, errors)
110}
111
112pub fn load_ir_modules<A: Embed>(
122 manifest: &AssetManifest,
123) -> (HashMap<String, RenderMode>, HashMap<String, IrModule>) {
124 let (render_modes, ir_modules, _errors) = load_ir_modules_inner::<A>(manifest);
125 (render_modes, ir_modules)
126}
127
128#[allow(clippy::type_complexity)]
135pub fn load_ir_modules_strict<A: Embed>(
136 manifest: &AssetManifest,
137) -> Result<(HashMap<String, RenderMode>, HashMap<String, IrModule>), Vec<IrLoadError>> {
138 let (render_modes, ir_modules, errors) = load_ir_modules_inner::<A>(manifest);
139 if errors.is_empty() {
140 Ok((render_modes, ir_modules))
141 } else {
142 Err(errors)
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::types::RouteAssets;
150 use rust_embed::Embed;
151
152 #[derive(Embed)]
153 #[folder = "test_fixtures/"]
154 struct Fixtures;
155
156 fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
157 let mut routes = HashMap::new();
158 routes.insert(
159 route.to_string(),
160 RouteAssets {
161 js: vec![],
162 css: vec![],
163 fonts: vec![],
164 total_size_br: 0,
165 budget_warn_threshold: 0,
166 ir: ir.map(str::to_string),
167 },
168 );
169 AssetManifest {
170 version: 1,
171 build_hash: "test".to_string(),
172 assets: HashMap::new(),
173 routes,
174 wasm: None,
175 }
176 }
177
178 #[test]
179 fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
180 let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
183 let errors = load_ir_modules_strict::<Fixtures>(&manifest).unwrap_err();
184 assert_eq!(errors.len(), 1);
185 assert_eq!(errors[0].route, "/");
186 assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
187
188 let (render_modes, ir_modules) = load_ir_modules::<Fixtures>(&manifest);
191 assert_eq!(render_modes.get("/"), Some(&RenderMode::Phase1ClientMount));
192 assert!(ir_modules.is_empty());
193 }
194
195 #[test]
196 fn a_route_with_no_ir_field_is_not_an_error() {
197 let manifest = manifest_with_ir("/", None);
199 let (render_modes, ir_modules) =
200 load_ir_modules_strict::<Fixtures>(&manifest).expect("must not error");
201 assert!(render_modes.is_empty());
202 assert!(ir_modules.is_empty());
203 }
204}