Skip to main content

forma_server/
ir_compat.rs

1use forma_ir::parser::IrModule;
2use rust_embed::Embed;
3use std::collections::HashMap;
4
5use crate::{AssetManifest, RenderMode};
6
7/// Why a route could not be served with Phase 2 SSR.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct IrLoadError {
10    /// The route pattern that failed.
11    pub route: String,
12    /// The `.ir` filename the manifest named for it.
13    pub ir_file: String,
14    /// Human-readable cause.
15    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
30/// Load and parse all IR modules from the asset manifest, collecting failures.
31///
32/// This is the primary implementation; [`load_ir_modules`] discards the errors
33/// and [`load_ir_modules_strict`] surfaces them, so the two can never drift.
34///
35/// A route with no `ir` field in the manifest is NOT an error — it simply has
36/// no IR and renders Phase 1. A route that DECLARES an `.ir` file which is not
37/// embedded IS an error: that is a build/embed skew, and it used to fall
38/// through silently with no log line and no `render_modes` entry, i.e. exactly
39/// indistinguishable from the legitimate case.
40fn 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            // No IR declared for this route — nothing to load, not a failure.
54            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            // Insert explicitly so the map is total: every declared route has
69            // a mode, rather than "absent" doing double duty.
70            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
112/// Load and parse all IR modules from the asset manifest, ignoring failures.
113///
114/// Every failure is logged and represented as `Phase1ClientMount`, so a broken
115/// build degrades instead of refusing to start. Use
116/// [`load_ir_modules_strict`] if you would rather fail startup on a
117/// build/embed skew than quietly ship without SSR.
118///
119/// Routes with no `.ir` field in the manifest are not included in the returned
120/// map — callers should treat absent routes as `Phase1ClientMount`.
121pub 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/// [`load_ir_modules`], but returns the collected failures so an app can fail
129/// startup on a build/embed skew rather than silently serving Phase 1.
130///
131/// Note the distinction the lenient variant cannot express: a missing embedded
132/// asset for a declared `.ir` is an error here, while a route that never had
133/// an `ir` field is not.
134#[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        // Previously this fell through with no log line and no render_modes
181        // entry — indistinguishable from a route that never had IR.
182        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        // ...and the lenient variant still degrades gracefully, with the map
189        // made total rather than relying on absence.
190        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        // The distinction the old code could not express.
198        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}