Skip to main content

forma_server/
ir_compat.rs

1use forma_ir::parser::IrModule;
2use rust_embed::Embed;
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::fmt;
6use std::marker::PhantomData;
7use std::sync::Arc;
8
9use crate::{AssetManifest, RenderMode};
10
11/// Why a route could not be served with Phase 2 SSR.
12///
13/// `reason` starts with a stable code, by family, then the cause:
14///
15/// | Code | Cause |
16/// |---|---|
17/// | LOAD-101 | The manifest declares a module that is not embedded |
18/// | LOAD-102 | Not a module this reader accepts: magic, version or flags |
19/// | LOAD-103 | A corrupt or truncated module: sections, tables, opcodes, names |
20///
21/// FMIR v2 carries no checksum, so a flipped byte the structure checks do not
22/// reach (inside the source hash, or inside a text string) is not refused;
23/// FMIR v3's CRC-32 closes that.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct IrLoadError {
26    /// The route pattern that failed.
27    pub route: String,
28    /// The `.ir` filename the manifest named for it.
29    pub ir_file: String,
30    /// The code and the human-readable cause, such as
31    /// `LOAD-102 bad magic: expected FMIR, got [71, 77, 73, 82]`.
32    pub reason: String,
33}
34
35impl IrLoadError {
36    /// The failure's code (`LOAD-101` to `LOAD-103`), read from the start of
37    /// `reason`; `None` for an error built by hand without one.
38    pub fn code(&self) -> Option<&str> {
39        let code = self.reason.split(' ').next()?;
40        let digits = code.strip_prefix("LOAD-")?;
41        (digits.len() == 3 && digits.bytes().all(|b| b.is_ascii_digit())).then_some(code)
42    }
43}
44
45/// The code for a module the parser refused.
46fn parse_failure_code(error: &forma_ir::IrError) -> &'static str {
47    use forma_ir::IrError;
48    match error {
49        IrError::BadMagic(_) | IrError::UnsupportedVersion(_) | IrError::UnsupportedFlags(_) => {
50            "LOAD-102"
51        }
52        _ => "LOAD-103",
53    }
54}
55
56impl std::fmt::Display for IrLoadError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(
59            f,
60            "route {}: IR file {}: {}",
61            self.route, self.ir_file, self.reason
62        )
63    }
64}
65
66impl std::error::Error for IrLoadError {}
67
68/// Reads one embedded file by name, as rust-embed's `Embed::get` does: in a
69/// debug build from disk on every call, in a release build from the bytes
70/// compiled into the binary. A parameter so a test can hand the loader its
71/// own files.
72type Read<'r> = &'r dyn Fn(&str) -> Option<Cow<'static, [u8]>>;
73
74/// Why one declared module was not loaded.
75enum Refusal {
76    /// The manifest names a file that is not embedded.
77    NotEmbedded,
78    /// The parser refused it.
79    Parse(forma_ir::IrError),
80}
81
82impl Refusal {
83    /// The coded error for `route`'s `ir_file`.
84    fn error(&self, route: &str, ir_file: &str) -> IrLoadError {
85        let reason = match self {
86            Refusal::NotEmbedded => {
87                "LOAD-101 declared in the manifest but not embedded".to_string()
88            }
89            Refusal::Parse(e) => format!("{} {e}", parse_failure_code(e)),
90        };
91        IrLoadError {
92            route: route.to_string(),
93            ir_file: ir_file.to_string(),
94            reason,
95        }
96    }
97}
98
99/// The one check every module goes through, at start and on a development
100/// reload: present, and parsed and validated by `IrModule::parse`.
101fn check_module(bytes: Option<&[u8]>) -> Result<IrModule, Refusal> {
102    let bytes = bytes.ok_or(Refusal::NotEmbedded)?;
103    IrModule::parse(bytes).map_err(Refusal::Parse)
104}
105
106/// One route's module as loaded, with the file and bytes it came from (which
107/// only a development reload reads).
108struct Loaded {
109    route: String,
110    #[cfg_attr(not(debug_assertions), allow(dead_code))]
111    ir_file: String,
112    #[cfg_attr(not(debug_assertions), allow(dead_code))]
113    bytes: Cow<'static, [u8]>,
114    module: IrModule,
115}
116
117/// Load and parse all IR modules from the asset manifest, collecting failures.
118///
119/// This is the primary implementation; [`load_ir_modules`] discards the errors
120/// and [`load_ir_modules_strict`] and [`IrModules::load`] surface them, so they
121/// can never drift. Only the lenient loader logs a failure, because only it
122/// hides one: the strict loaders hand every failure to their caller, which
123/// refuses to start.
124///
125/// A route with no `ir` field in the manifest is NOT an error — it simply has
126/// no IR and renders Phase 1. A route that DECLARES an `.ir` file which is not
127/// embedded IS an error: that is a build/embed skew, and it used to fall
128/// through silently with no log line and no `render_modes` entry, i.e. exactly
129/// indistinguishable from the legitimate case.
130fn load_from(
131    manifest: &AssetManifest,
132    read: Read<'_>,
133    log_fallbacks: bool,
134) -> (HashMap<String, RenderMode>, Vec<Loaded>, Vec<IrLoadError>) {
135    let mut render_modes = HashMap::new();
136    let mut loaded = Vec::new();
137    let mut errors = Vec::new();
138
139    for (route_pattern, route_assets) in &manifest.routes {
140        let Some(ref ir_filename) = route_assets.ir else {
141            // No IR declared for this route — nothing to load, not a failure.
142            continue;
143        };
144
145        let bytes = read(ir_filename);
146        match check_module(bytes.as_deref()) {
147            Ok(module) => {
148                tracing::info!(
149                    route = %route_pattern,
150                    ir_file = %ir_filename,
151                    "Loaded IR module for SSR"
152                );
153                render_modes.insert(route_pattern.clone(), RenderMode::Phase2SsrReconcile);
154                loaded.push(Loaded {
155                    route: route_pattern.clone(),
156                    ir_file: ir_filename.clone(),
157                    bytes: bytes.unwrap_or_default(),
158                    module,
159                });
160            }
161            Err(refusal) => {
162                if log_fallbacks {
163                    match &refusal {
164                        Refusal::NotEmbedded => tracing::warn!(
165                            route = %route_pattern,
166                            ir_file = %ir_filename,
167                            "Manifest declares an IR file that is not embedded — Phase 1 fallback"
168                        ),
169                        Refusal::Parse(e) => tracing::warn!(
170                            route = %route_pattern,
171                            ir_file = %ir_filename,
172                            error = %e,
173                            "Failed to parse IR module — Phase 1 fallback"
174                        ),
175                    }
176                }
177                errors.push(refusal.error(route_pattern, ir_filename));
178                // Insert explicitly so the map is total: every declared route
179                // has a mode, rather than "absent" doing double duty.
180                render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
181            }
182        }
183    }
184
185    if !loaded.is_empty() {
186        tracing::info!(
187            ssr_routes = loaded.len(),
188            "Phase 2 SSR enabled for {} route(s)",
189            loaded.len()
190        );
191    }
192
193    (render_modes, loaded, errors)
194}
195
196/// [`load_from`] over the embedded files of `A`, as two maps.
197fn load_ir_modules_inner<A: Embed>(
198    manifest: &AssetManifest,
199    log_fallbacks: bool,
200) -> (
201    HashMap<String, RenderMode>,
202    HashMap<String, IrModule>,
203    Vec<IrLoadError>,
204) {
205    let (render_modes, loaded, errors) = load_from(manifest, &embedded::<A>, log_fallbacks);
206    let ir_modules = loaded.into_iter().map(|l| (l.route, l.module)).collect();
207    (render_modes, ir_modules, errors)
208}
209
210/// One of `A`'s files, through rust-embed.
211fn embedded<A: Embed>(name: &str) -> Option<Cow<'static, [u8]>> {
212    A::get(name).map(|file| file.data)
213}
214
215/// Load and parse all IR modules from the asset manifest, ignoring failures.
216///
217/// Every failure is logged and represented as `Phase1ClientMount`, so a broken
218/// build degrades instead of refusing to start: a corrupt module's route is
219/// served as an empty shell for the life of the process.
220///
221/// Routes with no `.ir` field in the manifest are not included in the returned
222/// map — callers should treat absent routes as `Phase1ClientMount`.
223///
224/// Deprecated: [`load_ir_modules_strict`] is the default loader from
225/// 0.2.2, and 3.0.0 removes this one. It behaves exactly as at 0.2.1, and in
226/// debug builds logs its deprecation once per process.
227#[deprecated(
228    since = "0.2.2",
229    note = "serves a corrupt module's route as an empty shell for the life of the process; \
230            use `load_ir_modules_strict`, the default loader, and refuse to start on its \
231            errors. Removed in 3.0.0"
232)]
233pub fn load_ir_modules<A: Embed>(
234    manifest: &AssetManifest,
235) -> (HashMap<String, RenderMode>, HashMap<String, IrModule>) {
236    log_deprecation_once();
237    let (render_modes, ir_modules, _errors) = load_ir_modules_inner::<A>(manifest, true);
238    (render_modes, ir_modules)
239}
240
241/// The runtime half of `load_ir_modules`' deprecation: one `tracing` warning
242/// per process, in debug builds only.
243///
244/// Verified by: crates/forma-server/tests/load_ir_modules_deprecation.rs > "load_ir_modules_logs_its_deprecation_once_per_process_in_debug_builds"
245fn log_deprecation_once() {
246    #[cfg(debug_assertions)]
247    {
248        use std::sync::atomic::{AtomicBool, Ordering};
249        static LOGGED: AtomicBool = AtomicBool::new(false);
250        if !LOGGED.swap(true, Ordering::Relaxed) {
251            tracing::warn!(
252                "forma_server::load_ir_modules is deprecated: it serves a corrupt module's route \
253                 as an empty shell; use load_ir_modules_strict and refuse to start on its errors. \
254                 Logged once per process, in debug builds."
255            );
256        }
257    }
258}
259
260/// The module loader: every module the manifest declares, parsed and
261/// checked, or every failure, so the server can refuse to start.
262///
263/// The default from 0.2.2. Call it at start, before the port binds, and stop
264/// on `Err`: a module that is missing, corrupt, truncated or from another
265/// format version for a server-rendered route is a failed deploy, and CI and
266/// staging are where it should surface: one bad module refuses start,
267/// for every route. Each [`IrLoadError`] names the route, the file and
268/// a code. A route the build declares `ssr: false` carries no `ir` in the
269/// manifest and needs no module: it is not an error, and not in the maps.
270///
271/// ```no_run
272/// use forma_server::{assets, load_ir_modules_strict};
273///
274/// fn start<Assets: rust_embed::Embed>() -> Result<(), String> {
275///     let manifest = assets::try_load_manifest::<Assets>()?;
276///     let (render_modes, modules) = load_ir_modules_strict::<Assets>(&manifest).map_err(|errors| {
277///         let names: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
278///         names.join("; ") // e.g. "route /: IR file page.ir: LOAD-103 ..."
279///     })?;
280///     // ...only now bind the port.
281///     # let _ = (render_modes, modules);
282///     Ok(())
283/// }
284/// ```
285///
286/// To have a debug build reread a page's module when the build's watch mode
287/// rewrites it, load with [`IrModules::load`] instead: the same check and
288/// errors.
289///
290/// Verified by: crates/forma-server/tests/strict_loader.rs > "with_a_bad_module_the_fixture_server_binds_no_port"
291#[allow(clippy::type_complexity)]
292pub fn load_ir_modules_strict<A: Embed>(
293    manifest: &AssetManifest,
294) -> Result<(HashMap<String, RenderMode>, HashMap<String, IrModule>), Vec<IrLoadError>> {
295    let (render_modes, ir_modules, errors) = load_ir_modules_inner::<A>(manifest, false);
296    if errors.is_empty() {
297        Ok((render_modes, ir_modules))
298    } else {
299        Err(errors)
300    }
301}
302
303/// The page modules, loaded at start by the strict loader, and in a debug
304/// build reread when the build's watch mode rewrites one.
305///
306/// [`load_ir_modules_strict`] hands the application its modules once, so a
307/// page edited under `forma-build --watch` showed its change only after a
308/// server restart. An application that holds its modules here instead asks
309/// for a route's module per request, with [`IrModules::get`]:
310///
311/// - **In a release build** `get` is a map lookup. Nothing is watched or
312///   checked: the modules are the ones embedded in the binary, which cannot
313///   change.
314/// - **In a debug build** (`cfg(debug_assertions)`) `get` first rereads the
315///   manifest's entry for the route and the module's file, and when the bytes
316///   differ from the module it holds, loads the new one through the same check
317///   as start ([`IrModule::parse`], the strict loader's). A module that fails
318///   it keeps the previous one, and the failure is logged once per distinct
319///   file, as a `tracing` warning carrying its `LOAD-1xx` code; the next good
320///   write replaces it. A route the manifest starts naming after start is
321///   loaded on its first `get`; a route the manifest stops naming keeps its
322///   last module.
323///
324/// Why per request, and why debug builds: in a debug build rust-embed reads
325/// every file from disk on each call, so rereading the manifest and the module
326/// costs two small reads and a comparison, and the next request sees the
327/// build's last write, with no watcher thread, no file-watching dependency
328/// and no path for forma-server to know beyond the `Embed` type. The watch
329/// mode publishes a module by rename and only when its bytes change, so a read
330/// never sees half a file and an unchanged module is never reloaded. In a
331/// release build rust-embed serves the bytes compiled in, so there is nothing
332/// to reread, and the check is not compiled: the switch is rust-embed's own.
333/// (An application that enables rust-embed's `debug-embed` feature has its
334/// files compiled into debug builds too; `get` then finds nothing changed.)
335///
336/// "Debug build" here means forma-server compiled with debug assertions
337/// (`cfg(debug_assertions)`), not a profile's name. Cargo's default `release`
338/// profile turns them off, so a default release binary reads nothing per
339/// request. A profile that sets `debug-assertions = true`, for the whole
340/// build or for forma-server alone (`[profile.release.package.forma-server]`),
341/// compiles the check into what it calls a release binary: each `get` then
342/// rereads the files, from the build folder on disk (rust-embed reads from
343/// disk under the same switch), or, with `debug-embed`, from the embedded
344/// bytes at the cost of a lock and a comparison. Keep debug assertions off in
345/// the profile you deploy.
346///
347/// The strict loader's `render_modes` map has no counterpart here: a route is
348/// server-rendered exactly when `get(route)` returns a module, and a route
349/// without one (`ssr: false`) is served with
350/// [`render_browser_shell`](crate::render_browser_shell).
351///
352/// ```no_run
353/// use forma_server::{assets, IrModules, PageConfig};
354/// use std::sync::Arc;
355///
356/// // At start, before the port binds: refuse to start on any error.
357/// fn start<Assets: rust_embed::Embed>() -> Result<Arc<IrModules<Assets>>, String> {
358///     let manifest = assets::try_load_manifest::<Assets>()?;
359///     let modules = IrModules::<Assets>::load(&manifest).map_err(|errors| {
360///         errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ")
361///     })?;
362///     Ok(Arc::new(modules))
363/// }
364///
365/// // Per request: hold the route's module for the render.
366/// fn module<Assets: rust_embed::Embed>(modules: &IrModules<Assets>) -> Option<Arc<forma_ir::IrModule>> {
367///     modules.get("/") // then `ir_module: module.as_deref()` in the PageConfig
368/// }
369/// ```
370///
371/// Verified by: crates/forma-server/tests/dev_reload.rs > "an_edited_page_is_served_on_the_next_request_and_a_broken_edit_keeps_the_previous_page"
372/// Verified by: crates/forma-server/src/ir_compat.rs > "a_release_build_reads_nothing_per_request_and_a_debug_build_rereads"
373pub struct IrModules<A> {
374    modules: Modules,
375    assets: PhantomData<fn() -> A>,
376}
377
378impl<A: Embed> IrModules<A> {
379    /// Load every module the manifest declares, through the strict loader:
380    /// the modules, or every failure, as [`load_ir_modules_strict`] returns
381    /// them. Call it at start and refuse to start on `Err`.
382    ///
383    /// # Errors
384    ///
385    /// One [`IrLoadError`] per module that is not embedded or fails its
386    /// check, naming route, file and code.
387    pub fn load(manifest: &AssetManifest) -> Result<Self, Vec<IrLoadError>> {
388        Ok(IrModules {
389            modules: Modules::load(manifest, &embedded::<A>)?,
390            assets: PhantomData,
391        })
392    }
393
394    /// The route's module, or `None` for a route with none (a route the
395    /// build declares `ssr: false`, or one the manifest never named). In a
396    /// debug build, the module the build last wrote, if it loads; see the
397    /// type's documentation.
398    pub fn get(&self, route_pattern: &str) -> Option<Arc<IrModule>> {
399        self.modules.get(route_pattern, &embedded::<A>)
400    }
401}
402
403impl<A> fmt::Debug for IrModules<A> {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        f.debug_struct("IrModules")
406            .field("routes", &self.modules.routes())
407            .finish()
408    }
409}
410
411/// [`IrModules`]' state, apart from the `Embed` type, so a test can hand it
412/// its own files.
413struct Modules {
414    #[cfg(not(debug_assertions))]
415    by_route: HashMap<String, Arc<IrModule>>,
416    #[cfg(debug_assertions)]
417    dev: std::sync::Mutex<dev::Pages>,
418}
419
420impl Modules {
421    fn load(manifest: &AssetManifest, read: Read<'_>) -> Result<Self, Vec<IrLoadError>> {
422        let (_, loaded, errors) = load_from(manifest, read, false);
423        if !errors.is_empty() {
424            return Err(errors);
425        }
426        #[cfg(not(debug_assertions))]
427        let modules = Modules {
428            by_route: loaded
429                .into_iter()
430                .map(|l| (l.route, Arc::new(l.module)))
431                .collect(),
432        };
433        #[cfg(debug_assertions)]
434        let modules = Modules {
435            dev: std::sync::Mutex::new(dev::Pages::new(manifest, loaded, read)),
436        };
437        Ok(modules)
438    }
439
440    /// A release build: the module loaded at start. `read` is not called.
441    #[cfg(not(debug_assertions))]
442    fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
443        let _ = read;
444        self.by_route.get(route).cloned()
445    }
446
447    /// A debug build: the module the build last wrote, if it loads.
448    #[cfg(debug_assertions)]
449    fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
450        // Nothing panics while holding the lock; a poisoned state is still
451        // the last good modules.
452        let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
453        pages.get(route, read)
454    }
455
456    fn routes(&self) -> Vec<String> {
457        #[cfg(not(debug_assertions))]
458        let mut routes: Vec<String> = self.by_route.keys().cloned().collect();
459        #[cfg(debug_assertions)]
460        let mut routes = self.dev.lock().unwrap_or_else(|p| p.into_inner()).routes();
461        routes.sort();
462        routes
463    }
464}
465
466/// The development reload (debug builds only).
467#[cfg(debug_assertions)]
468mod dev {
469    use super::{check_module, Loaded, Read};
470    use crate::AssetManifest;
471    use forma_ir::parser::IrModule;
472    use std::borrow::Cow;
473    use std::collections::HashMap;
474    use std::sync::Arc;
475
476    /// What a debug build knows about the modules and the manifest.
477    pub(super) struct Pages {
478        /// `manifest.json` as last read, parsed or refused.
479        manifest: Option<Vec<u8>>,
480        /// Each route's module file, as the manifest last named it.
481        names: HashMap<String, String>,
482        /// Each route's module and what it was loaded from, or why not.
483        pages: HashMap<String, Page>,
484    }
485
486    #[derive(Default)]
487    struct Page {
488        /// The module served, with the file and bytes it was parsed from.
489        current: Option<(String, Cow<'static, [u8]>, Arc<IrModule>)>,
490        /// The file and bytes last refused, so a broken write is logged once.
491        refused: Option<(String, Option<Vec<u8>>)>,
492    }
493
494    impl Pages {
495        pub(super) fn new(manifest: &AssetManifest, loaded: Vec<Loaded>, read: Read<'_>) -> Self {
496            let names = manifest
497                .routes
498                .iter()
499                .filter_map(|(route, assets)| Some((route.clone(), assets.ir.clone()?)))
500                .collect();
501            let pages = loaded
502                .into_iter()
503                .map(|l| {
504                    let page = Page {
505                        current: Some((l.ir_file, l.bytes, Arc::new(l.module))),
506                        refused: None,
507                    };
508                    (l.route, page)
509                })
510                .collect();
511            Pages {
512                // The manifest on disk now is the baseline: a later change to
513                // it is what a `get` looks for.
514                manifest: read("manifest.json").map(Cow::into_owned),
515                names,
516                pages,
517            }
518        }
519
520        pub(super) fn routes(&self) -> Vec<String> {
521            self.pages
522                .iter()
523                .filter(|(_, p)| p.current.is_some())
524                .map(|(r, _)| r.clone())
525                .collect()
526        }
527
528        pub(super) fn get(&mut self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
529            self.reread_manifest(read);
530            let Some(file) = self.names.get(route).cloned() else {
531                return self.current(route);
532            };
533            let bytes = read(&file);
534            let page = self.pages.entry(route.to_string()).or_default();
535            let unchanged = page
536                .current
537                .as_ref()
538                .is_some_and(|(loaded_from, loaded, _)| {
539                    *loaded_from == file && Some(loaded) == bytes.as_ref()
540                });
541            if unchanged {
542                // The build wrote back what is served: a later refusal of
543                // the same broken bytes is news again.
544                page.refused = None;
545                return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
546            }
547            let attempt = (file.clone(), bytes.as_deref().map(<[u8]>::to_vec));
548            if page.refused.as_ref() == Some(&attempt) {
549                // Refused and logged already; still the previous module.
550                return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
551            }
552            match check_module(bytes.as_deref()) {
553                Ok(module) => {
554                    tracing::info!(
555                        route,
556                        ir_file = %file,
557                        "Reloaded the IR module the build rewrote (development)"
558                    );
559                    page.current = Some((file, bytes.unwrap_or_default(), Arc::new(module)));
560                    page.refused = None;
561                }
562                Err(refusal) => {
563                    let error = refusal.error(route, &file);
564                    tracing::warn!(
565                        code = error.code().unwrap_or_default(),
566                        route,
567                        ir_file = %file,
568                        "{error}; the previous module stays until the build writes one that \
569                         loads (development reload)"
570                    );
571                    page.refused = Some(attempt);
572                }
573            }
574            page.current.as_ref().map(|(_, _, m)| Arc::clone(m))
575        }
576
577        fn current(&self, route: &str) -> Option<Arc<IrModule>> {
578            self.pages
579                .get(route)
580                .and_then(|p| p.current.as_ref())
581                .map(|(_, _, m)| Arc::clone(m))
582        }
583
584        /// Take the module names from `manifest.json` when it changed. One the
585        /// watch mode publishes is whole (it is replaced by rename); one that
586        /// does not parse keeps the names and is logged once.
587        fn reread_manifest(&mut self, read: Read<'_>) {
588            let Some(bytes) = read("manifest.json") else {
589                return;
590            };
591            if self.manifest.as_deref() == Some(&bytes[..]) {
592                return;
593            }
594            match serde_json::from_slice::<AssetManifest>(&bytes) {
595                Ok(manifest) => {
596                    for (route, assets) in manifest.routes {
597                        if let Some(ir) = assets.ir {
598                            self.names.insert(route, ir);
599                        }
600                    }
601                }
602                Err(e) => tracing::warn!(
603                    "manifest.json does not parse ({e}); the module names stay as they were \
604                     (development reload)"
605                ),
606            }
607            self.manifest = Some(bytes.into_owned());
608        }
609    }
610}
611
612#[cfg(test)]
613#[allow(deprecated)] // the lenient loader's 0.2.1 behaviour is pinned here too
614mod tests {
615    use super::*;
616    use crate::types::RouteAssets;
617    use rust_embed::Embed;
618
619    #[derive(Embed)]
620    #[folder = "test_fixtures/"]
621    struct Fixtures;
622
623    fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
624        let mut routes = HashMap::new();
625        routes.insert(
626            route.to_string(),
627            RouteAssets {
628                js: vec![],
629                css: vec![],
630                fonts: vec![],
631                total_size_br: 0,
632                budget_warn_threshold: 0,
633                ir: ir.map(str::to_string),
634            },
635        );
636        AssetManifest {
637            version: 1,
638            build_hash: "test".to_string(),
639            assets: HashMap::new(),
640            routes,
641            wasm: None,
642        }
643    }
644
645    #[test]
646    fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
647        // Previously this fell through with no log line and no render_modes
648        // entry — indistinguishable from a route that never had IR.
649        let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
650        let errors = load_ir_modules_strict::<Fixtures>(&manifest).unwrap_err();
651        assert_eq!(errors.len(), 1);
652        assert_eq!(errors[0].route, "/");
653        assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
654        assert_eq!(errors[0].code(), Some("LOAD-101"));
655
656        // ...and the lenient variant still degrades gracefully, with the map
657        // made total rather than relying on absence.
658        let (render_modes, ir_modules) = load_ir_modules::<Fixtures>(&manifest);
659        assert_eq!(render_modes.get("/"), Some(&RenderMode::Phase1ClientMount));
660        assert!(ir_modules.is_empty());
661    }
662
663    #[test]
664    fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
665        let error = |reason: &str| IrLoadError {
666            route: "/".to_string(),
667            ir_file: "page.ir".to_string(),
668            reason: reason.to_string(),
669        };
670        assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
671        assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
672        // Built by hand without one, or something code-shaped that is not.
673        for reason in [
674            "bad magic",
675            "",
676            "LOAD-1 x",
677            "LOAD-1023 x",
678            "LOAD-10x y",
679            "load-102 x",
680        ] {
681            assert_eq!(error(reason).code(), None, "{reason:?}");
682        }
683        // The parser's errors map to their family.
684        assert_eq!(
685            parse_failure_code(&forma_ir::IrError::BadMagic(*b"GMIR")),
686            "LOAD-102"
687        );
688        assert_eq!(
689            parse_failure_code(&forma_ir::IrError::UnsupportedVersion(3)),
690            "LOAD-102"
691        );
692        assert_eq!(
693            parse_failure_code(&forma_ir::IrError::UnsupportedFlags(1)),
694            "LOAD-102"
695        );
696        assert_eq!(
697            parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
698            "LOAD-103"
699        );
700    }
701
702    #[test]
703    fn a_route_with_no_ir_field_is_not_an_error() {
704        // The distinction the old code could not express.
705        let manifest = manifest_with_ir("/", None);
706        let (render_modes, ir_modules) =
707            load_ir_modules_strict::<Fixtures>(&manifest).expect("must not error");
708        assert!(render_modes.is_empty());
709        assert!(ir_modules.is_empty());
710    }
711}
712
713/// [`IrModules`]' reload, over files a test controls: the reader is the
714/// file-system boundary rust-embed reads in a debug build.
715#[cfg(test)]
716mod reload_tests {
717    use super::*;
718    use std::sync::atomic::{AtomicUsize, Ordering};
719    use std::sync::Mutex;
720
721    /// The files rust-embed would read, and how many reads it was asked for.
722    #[derive(Default)]
723    struct Disk {
724        files: Mutex<HashMap<String, Vec<u8>>>,
725        reads: AtomicUsize,
726    }
727
728    impl Disk {
729        fn write(&self, name: &str, bytes: Vec<u8>) {
730            self.files
731                .lock()
732                .expect("lock")
733                .insert(name.to_string(), bytes);
734        }
735        fn remove(&self, name: &str) {
736            self.files.lock().expect("lock").remove(name);
737        }
738        fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
739            self.reads.fetch_add(1, Ordering::SeqCst);
740            self.files
741                .lock()
742                .expect("lock")
743                .get(name)
744                .cloned()
745                .map(Cow::Owned)
746        }
747        fn reads(&self) -> usize {
748            self.reads.load(Ordering::SeqCst)
749        }
750    }
751
752    /// A module rendering `<p>{text}</p>`.
753    fn page(text: &str) -> Vec<u8> {
754        use forma_ir::parser::test_helpers::{
755            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
756        };
757        let mut ops = encode_open_tag(0, &[]);
758        ops.extend_from_slice(&encode_text(1));
759        ops.extend_from_slice(&encode_close_tag(0));
760        build_minimal_ir(&["p", text], &[], &ops, &[])
761    }
762
763    fn html(module: Option<Arc<IrModule>>) -> String {
764        let module = module.expect("a module");
765        forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
766    }
767
768    /// The manifest JSON naming each route's module (`None`: no module).
769    fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
770        let routes: serde_json::Map<String, serde_json::Value> = routes
771            .iter()
772            .map(|(route, ir)| {
773                let mut assets = serde_json::json!({
774                    "js": ["app.js"], "css": [], "fonts": [],
775                    "total_size_br": 0, "budget_warn_threshold": 0
776                });
777                if let Some(ir) = ir {
778                    assets["ir"] = (*ir).into();
779                }
780                (route.to_string(), assets)
781            })
782            .collect();
783        serde_json::json!({"version": 1, "build_hash": "dev", "assets": {}, "routes": routes})
784            .to_string()
785            .into_bytes()
786    }
787
788    /// The disk with `/` naming `page.ir` and `/app` naming none, and the
789    /// manifest the application read from it at start.
790    fn started(first: &str) -> (Disk, AssetManifest) {
791        let disk = Disk::default();
792        let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
793        disk.write("manifest.json", json.clone());
794        disk.write("page.ir", page(first));
795        let manifest = serde_json::from_slice(&json).expect("manifest");
796        (disk, manifest)
797    }
798
799    #[test]
800    fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
801        let (disk, manifest) = started("first");
802        let read = |name: &str| disk.read(name);
803        let modules = Modules::load(&manifest, &read).expect("loads");
804        let after_load = disk.reads();
805        assert!(after_load >= 1, "the start reads the module");
806
807        for _ in 0..3 {
808            assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
809        }
810        assert!(
811            modules.get("/app", &read).is_none(),
812            "a route with no module"
813        );
814        // The build rewrites the page's module.
815        disk.write("page.ir", page("second"));
816        let served = html(modules.get("/", &read));
817
818        if cfg!(debug_assertions) {
819            assert_eq!(
820                served, "<p>second</p>",
821                "debug: the next get serves the rewrite"
822            );
823            // Each get reads the manifest and the route's module, if it has one.
824            assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
825        } else {
826            assert_eq!(
827                served, "<p>first</p>",
828                "release: the module loaded at start"
829            );
830            assert_eq!(disk.reads(), after_load, "release: no read after start");
831        }
832    }
833
834    #[test]
835    fn the_reload_loads_through_the_strict_loader_at_start() {
836        // A bad module at start is a refused start, in every build, with the
837        // strict loader's codes.
838        let (disk, manifest) = started("first");
839        let mut flipped = page("first");
840        flipped[0] ^= 0xFF;
841        disk.write("page.ir", flipped);
842        let read = |name: &str| disk.read(name);
843        let errors = Modules::load(&manifest, &read).err().expect("refused");
844        assert_eq!(errors.len(), 1);
845        assert_eq!(errors[0].route, "/");
846        assert_eq!(errors[0].ir_file, "page.ir");
847        assert_eq!(errors[0].code(), Some("LOAD-102"));
848        disk.remove("page.ir");
849        let errors = Modules::load(&manifest, &read).err().expect("refused");
850        assert_eq!(errors[0].code(), Some("LOAD-101"));
851    }
852
853    #[cfg(debug_assertions)]
854    mod development {
855        use super::*;
856        use tracing::field::{Field, Visit};
857        use tracing::span::{Attributes, Id, Record};
858        use tracing::{Event, Metadata, Subscriber};
859
860        /// The warnings logged, as (code, message).
861        #[derive(Clone, Default)]
862        struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
863
864        impl Subscriber for Warnings {
865            fn enabled(&self, _: &Metadata<'_>) -> bool {
866                true
867            }
868            fn new_span(&self, _: &Attributes<'_>) -> Id {
869                Id::from_u64(1)
870            }
871            fn record(&self, _: &Id, _: &Record<'_>) {}
872            fn record_follows_from(&self, _: &Id, _: &Id) {}
873            fn event(&self, event: &Event<'_>) {
874                #[derive(Default)]
875                struct Fields(String, String);
876                impl Visit for Fields {
877                    fn record_str(&mut self, field: &Field, value: &str) {
878                        if field.name() == "code" {
879                            self.0 = value.to_string();
880                        }
881                    }
882                    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
883                        if field.name() == "message" {
884                            self.1 = format!("{value:?}");
885                        }
886                    }
887                }
888                if *event.metadata().level() == tracing::Level::WARN {
889                    let mut fields = Fields::default();
890                    event.record(&mut fields);
891                    self.0.lock().expect("lock").push((fields.0, fields.1));
892                }
893            }
894            fn enter(&self, _: &Id) {}
895            fn exit(&self, _: &Id) {}
896        }
897
898        impl Warnings {
899            fn codes(&self) -> Vec<String> {
900                self.0
901                    .lock()
902                    .expect("lock")
903                    .iter()
904                    .map(|(c, _)| c.clone())
905                    .collect()
906            }
907        }
908
909        #[test]
910        fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
911            let (disk, manifest) = started("first");
912            let read = |name: &str| disk.read(name);
913            let modules = Modules::load(&manifest, &read).expect("loads");
914            let warnings = Warnings::default();
915            tracing::subscriber::with_default(warnings.clone(), || {
916                // A truncated module: refused, the previous one served, one
917                // warning however many requests read it.
918                let good = page("second");
919                disk.write("page.ir", good[..good.len() - 3].to_vec());
920                for _ in 0..3 {
921                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
922                }
923                assert_eq!(warnings.codes(), ["LOAD-103"]);
924                let (_, message) = warnings.0.lock().expect("lock")[0].clone();
925                assert!(
926                    message.contains("route /: IR file page.ir: LOAD-103"),
927                    "{message}"
928                );
929                assert!(message.contains("the previous module stays"), "{message}");
930
931                // Not a module at all (bad magic): its own code, once.
932                let mut magic = page("second");
933                magic[0] ^= 0xFF;
934                disk.write("page.ir", magic.clone());
935                for _ in 0..3 {
936                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
937                }
938                // Deleted: LOAD-101, once.
939                disk.remove("page.ir");
940                for _ in 0..3 {
941                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
942                }
943                assert_eq!(warnings.codes(), ["LOAD-103", "LOAD-102", "LOAD-101"]);
944
945                // The next good write is served...
946                disk.write("page.ir", page("second"));
947                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
948                // ...and breaking it the same way again is news again.
949                disk.write("page.ir", magic.clone());
950                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
951                assert_eq!(
952                    warnings.codes(),
953                    ["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102"]
954                );
955                // So is the same break after the build wrote back the module
956                // being served (an undo), which reloads nothing.
957                disk.write("page.ir", page("second"));
958                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
959                disk.write("page.ir", magic);
960                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
961                assert_eq!(
962                    warnings.codes(),
963                    ["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
964                );
965            });
966        }
967
968        #[test]
969        fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
970            let (disk, manifest) = started("first");
971            let read = |name: &str| disk.read(name);
972            let modules = Modules::load(&manifest, &read).expect("loads");
973            assert!(modules.get("/late", &read).is_none());
974            let warnings = Warnings::default();
975            tracing::subscriber::with_default(warnings.clone(), || {
976                // A rebuilt production manifest: new names for both pages,
977                // and a route that had no module now has one.
978                disk.write("page.2.ir", page("renamed"));
979                disk.write("late.ir", page("late"));
980                disk.write(
981                    "manifest.json",
982                    manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
983                );
984                assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
985                assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
986                assert!(warnings.codes().is_empty());
987
988                // A manifest that does not parse: logged once, names kept.
989                disk.write("manifest.json", b"{\"routes\":".to_vec());
990                for _ in 0..3 {
991                    assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
992                }
993                let logged = warnings.0.lock().expect("lock").clone();
994                assert_eq!(logged.len(), 1, "{logged:?}");
995                assert!(
996                    logged[0].1.contains("manifest.json does not parse"),
997                    "{logged:?}"
998                );
999
1000                // A manifest that stops naming a route's module: the last
1001                // module stays.
1002                disk.write("manifest.json", manifest_json(&[("/", None)]));
1003                assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
1004                assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
1005            });
1006        }
1007    }
1008}