Skip to main content

forma_server/
ir.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, RouteMode};
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 | Bad signature or legacy FMIR module requiring a rebuild |
19/// | LOAD-103 | A corrupt or truncated module: sections, tables, opcodes, names |
20///
21/// FMIR v3 checks length and CRC before parsing sections (`LOAD-104`). Missing
22/// or unknown required sections use `LOAD-105`/`LOAD-106`; limits `LOAD-107`,
23/// incompatible writer versions `LOAD-108`. Codes come from forma-ir.
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 signature: expected FMIR v3 signature`.
32    pub reason: String,
33}
34
35impl IrLoadError {
36    /// The failure's code (`LOAD-101` to `LOAD-108`), 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    error.code()
48}
49
50impl std::fmt::Display for IrLoadError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(
53            f,
54            "route {}: IR file {}: {}",
55            self.route, self.ir_file, self.reason
56        )
57    }
58}
59
60impl std::error::Error for IrLoadError {}
61
62/// Reads one embedded file by name, as rust-embed's `Embed::get` does: in a
63/// debug build from disk on every call, in a release build from the bytes
64/// compiled into the binary. A parameter so a test can hand the loader its
65/// own files.
66type Read<'r> = &'r dyn Fn(&str) -> Option<Cow<'static, [u8]>>;
67
68/// Why one declared module was not loaded.
69enum Refusal {
70    /// The manifest names a file that is not embedded.
71    NotEmbedded,
72    /// The parser refused it.
73    Parse(forma_ir::IrError),
74}
75
76impl Refusal {
77    /// The coded error for `route`'s `ir_file`.
78    fn error(&self, route: &str, ir_file: &str) -> IrLoadError {
79        let reason = match self {
80            Refusal::NotEmbedded => {
81                "LOAD-101 declared in the manifest but not embedded".to_string()
82            }
83            Refusal::Parse(e) => format!("{} {e}", parse_failure_code(e)),
84        };
85        IrLoadError {
86            route: route.to_string(),
87            ir_file: ir_file.to_string(),
88            reason,
89        }
90    }
91}
92
93/// The one check every module goes through, at start and on a development
94/// reload: present, and parsed and validated by `IrModule::parse`.
95fn check_module(bytes: Option<&[u8]>) -> Result<IrModule, Refusal> {
96    let bytes = bytes.ok_or(Refusal::NotEmbedded)?;
97    IrModule::parse(bytes).map_err(Refusal::Parse)
98}
99
100/// One route's module as loaded, with the file and bytes it came from (which
101/// only a development reload reads).
102struct Loaded {
103    route: String,
104    #[cfg_attr(not(debug_assertions), allow(dead_code))]
105    ir_file: String,
106    #[cfg_attr(not(debug_assertions), allow(dead_code))]
107    bytes: Cow<'static, [u8]>,
108    module: IrModule,
109}
110
111/// Load and parse all IR modules from the asset manifest, collecting failures.
112///
113/// All public loaders surface these failures and refuse startup. There is no
114/// lenient fallback route through this implementation.
115///
116/// An explicit browser route has no module. An SSR route must declare one;
117/// schema validation refuses a missing declaration before any module reads. A route that DECLARES an `.ir` file which is not
118/// embedded IS an error: that is a build/embed skew, and it used to fall
119/// through silently with no log line and no `render_modes` entry, i.e. exactly
120/// indistinguishable from the legitimate case.
121fn load_from(
122    manifest: &AssetManifest,
123    read: Read<'_>,
124) -> (HashMap<String, RouteMode>, Vec<Loaded>, Vec<IrLoadError>) {
125    let mut render_modes = HashMap::new();
126    let mut loaded = Vec::new();
127    let mut errors = Vec::new();
128    if let Err(reason) = manifest.validate() {
129        errors.push(IrLoadError {
130            route: "<manifest>".into(),
131            ir_file: "manifest.json".into(),
132            reason,
133        });
134        return (render_modes, loaded, errors);
135    }
136
137    for (route_pattern, route_assets) in &manifest.routes {
138        if route_assets.mode == crate::RouteMode::Browser {
139            render_modes.insert(route_pattern.clone(), RouteMode::Browser);
140            continue;
141        }
142        let Some(ref ir_filename) = route_assets.ir else {
143            continue;
144        };
145
146        let bytes = read(ir_filename);
147        match check_module(bytes.as_deref()) {
148            Ok(module) => {
149                tracing::info!(
150                    route = %route_pattern,
151                    ir_file = %ir_filename,
152                    "Loaded IR module for SSR"
153                );
154                render_modes.insert(route_pattern.clone(), RouteMode::Ssr);
155                loaded.push(Loaded {
156                    route: route_pattern.clone(),
157                    ir_file: ir_filename.clone(),
158                    bytes: bytes.unwrap_or_default(),
159                    module,
160                });
161            }
162            Err(refusal) => {
163                errors.push(refusal.error(route_pattern, ir_filename));
164            }
165        }
166    }
167
168    if !loaded.is_empty() {
169        tracing::info!(
170            ssr_routes = loaded.len(),
171            "Phase 2 SSR enabled for {} route(s)",
172            loaded.len()
173        );
174    }
175
176    (render_modes, loaded, errors)
177}
178
179/// One of `A`'s files, through rust-embed.
180fn embedded<A: Embed>(name: &str) -> Option<Cow<'static, [u8]>> {
181    A::get(name).map(|file| file.data)
182}
183
184/// Strictly load the page registry. Every declared SSR module must validate before
185/// any handler can bind or the application opens its listener. Browser routes
186/// remain explicit and cannot be bound as server-rendered pages.
187pub fn load_ir_modules<A: Embed + 'static>(
188    manifest: &AssetManifest,
189) -> Result<crate::Pages, Vec<IrLoadError>> {
190    crate::Pages::load::<A>(manifest)
191}
192
193/// Private cache behind the single public Pages loader.
194pub(crate) struct IrModules<A> {
195    modules: Modules,
196    assets: PhantomData<fn() -> A>,
197}
198
199impl<A: Embed> IrModules<A> {
200    /// Load every module the manifest declares, through the strict loader:
201    /// the modules, or every failure, as [`load_ir_modules`] returns
202    /// them. Call it at start and refuse to start on `Err`.
203    ///
204    /// # Errors
205    ///
206    /// One [`IrLoadError`] per module that is not embedded or fails its
207    /// check, naming route, file and code.
208    pub(crate) fn load(manifest: &AssetManifest) -> Result<Self, Vec<IrLoadError>> {
209        Ok(IrModules {
210            modules: Modules::load(manifest, &embedded::<A>)?,
211            assets: PhantomData,
212        })
213    }
214
215    pub(crate) fn freeze_topology(&self) -> Result<(), String> {
216        #[cfg(debug_assertions)]
217        {
218            self.modules
219                .dev
220                .lock()
221                .unwrap_or_else(|p| p.into_inner())
222                .freeze_topology()
223        }
224        #[cfg(not(debug_assertions))]
225        {
226            Ok(())
227        }
228    }
229
230    /// Atomically pair the module with the validated manifest that named it.
231    /// A refused development write keeps both parts of the previous snapshot.
232    pub(crate) fn snapshot(&self, route: &str) -> Option<(Arc<IrModule>, Arc<AssetManifest>)> {
233        self.modules.snapshot(route, &embedded::<A>)
234    }
235}
236
237impl<A> fmt::Debug for IrModules<A> {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.debug_struct("IrModules")
240            .field("routes", &self.modules.routes())
241            .finish()
242    }
243}
244
245/// [`IrModules`]' state, apart from the `Embed` type, so a test can hand it
246/// its own files.
247struct Modules {
248    #[cfg(not(debug_assertions))]
249    manifest: Arc<AssetManifest>,
250    #[cfg(not(debug_assertions))]
251    by_route: HashMap<String, Arc<IrModule>>,
252    #[cfg(debug_assertions)]
253    dev: std::sync::Mutex<dev::Pages>,
254}
255
256impl Modules {
257    fn load(manifest: &AssetManifest, read: Read<'_>) -> Result<Self, Vec<IrLoadError>> {
258        let (_, loaded, errors) = load_from(manifest, read);
259        if !errors.is_empty() {
260            return Err(errors);
261        }
262        #[cfg(not(debug_assertions))]
263        let modules = Modules {
264            manifest: Arc::new(manifest.clone()),
265            by_route: loaded
266                .into_iter()
267                .map(|l| (l.route, Arc::new(l.module)))
268                .collect(),
269        };
270        #[cfg(debug_assertions)]
271        let modules = Modules {
272            dev: std::sync::Mutex::new(dev::Pages::new(manifest, loaded, read)),
273        };
274        Ok(modules)
275    }
276
277    /// A release build: the module loaded at start. `read` is not called.
278    #[cfg(not(debug_assertions))]
279    fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
280        let _ = read;
281        self.by_route.get(route).cloned()
282    }
283
284    /// A debug build: the module the build last wrote, if it loads.
285    #[cfg(all(debug_assertions, test))]
286    fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
287        // Nothing panics while holding the lock; a poisoned state is still
288        // the last good modules.
289        let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
290        pages.get(route, read)
291    }
292
293    fn snapshot(&self, route: &str, read: Read<'_>) -> Option<(Arc<IrModule>, Arc<AssetManifest>)> {
294        #[cfg(not(debug_assertions))]
295        {
296            self.get(route, read)
297                .map(|module| (module, Arc::clone(&self.manifest)))
298        }
299        #[cfg(debug_assertions)]
300        {
301            let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
302            let module = pages.get(route, read)?;
303            Some((module, pages.manifest_for(route)?))
304        }
305    }
306
307    fn routes(&self) -> Vec<String> {
308        #[cfg(not(debug_assertions))]
309        let mut routes: Vec<String> = self.by_route.keys().cloned().collect();
310        #[cfg(debug_assertions)]
311        let mut routes = self.dev.lock().unwrap_or_else(|p| p.into_inner()).routes();
312        routes.sort();
313        routes
314    }
315}
316
317/// The development reload (debug builds only).
318#[cfg(debug_assertions)]
319mod dev {
320    use super::{check_module, Loaded, Read};
321    use crate::{AssetManifest, RouteMode};
322    use forma_ir::parser::IrModule;
323    use std::borrow::Cow;
324    use std::collections::HashMap;
325    use std::sync::Arc;
326
327    /// What a debug build knows about the modules and the manifest.
328    pub(super) struct Pages {
329        /// `manifest.json` as last read, parsed or refused.
330        manifest: Option<Vec<u8>>,
331        /// Invalid or missing manifest bytes freeze the complete last-good pair.
332        /// Repeated identical refusals must not permit a mutable .ir file reload.
333        manifest_rejected: bool,
334        accepted_manifest: Arc<AssetManifest>,
335        initial_topology: HashMap<String, RouteMode>,
336        frozen_topology: bool,
337        /// Each route's module file, as the manifest last named it.
338        names: HashMap<String, String>,
339        /// Each route's module and what it was loaded from, or why not.
340        pages: HashMap<String, Page>,
341    }
342
343    #[derive(Default)]
344    struct Page {
345        manifest: Option<Arc<AssetManifest>>,
346        /// The module served, with the file and bytes it was parsed from.
347        current: Option<(String, Cow<'static, [u8]>, Arc<IrModule>)>,
348        /// The file and bytes last refused, so a broken write is logged once.
349        refused: Option<(String, Option<Vec<u8>>)>,
350    }
351
352    fn topology(manifest: &AssetManifest) -> HashMap<String, RouteMode> {
353        manifest
354            .routes
355            .iter()
356            .map(|(name, route)| (name.clone(), route.mode))
357            .collect()
358    }
359
360    impl Pages {
361        pub(super) fn new(manifest: &AssetManifest, loaded: Vec<Loaded>, read: Read<'_>) -> Self {
362            let names = manifest
363                .routes
364                .iter()
365                .filter_map(|(route, assets)| Some((route.clone(), assets.ir.clone()?)))
366                .collect();
367            let pages = loaded
368                .into_iter()
369                .map(|l| {
370                    let page = Page {
371                        manifest: Some(Arc::new(manifest.clone())),
372                        current: Some((l.ir_file, l.bytes, Arc::new(l.module))),
373                        refused: None,
374                    };
375                    (l.route, page)
376                })
377                .collect();
378            // Preserve the caller's validated startup manifest, including any
379            // explicit route additions. Disk bytes establish the watch baseline,
380            // but an invalid or missing baseline must not permit IR-only reload.
381            let baseline = read("manifest.json").map(Cow::into_owned);
382            let manifest_rejected = match baseline.as_deref() {
383                Some(bytes) => match serde_json::from_slice::<AssetManifest>(bytes) {
384                    Ok(_) => false,
385                    Err(error) => {
386                        tracing::warn!("manifest.json does not parse ({error}); the previous module and assets stay together (development reload)");
387                        true
388                    }
389                },
390                None => {
391                    tracing::warn!("manifest.json is missing; the previous module and assets stay together (development reload)");
392                    true
393                }
394            };
395            Pages {
396                accepted_manifest: Arc::new(manifest.clone()),
397                initial_topology: topology(manifest),
398                frozen_topology: false,
399                manifest: baseline,
400                manifest_rejected,
401                names,
402                pages,
403            }
404        }
405
406        pub(super) fn freeze_topology(&mut self) -> Result<(), String> {
407            if topology(&self.accepted_manifest) != self.initial_topology {
408                return Err("route names or modes changed during startup; reload the registry and bind again".into());
409            }
410            self.frozen_topology = true;
411            Ok(())
412        }
413
414        pub(super) fn routes(&self) -> Vec<String> {
415            self.pages
416                .iter()
417                .filter(|(_, p)| p.current.is_some())
418                .map(|(r, _)| r.clone())
419                .collect()
420        }
421
422        pub(super) fn get(&mut self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
423            if !self.reread_manifest(read) {
424                return self.current(route);
425            }
426            let Some(file) = self.names.get(route).cloned() else {
427                return self.current(route);
428            };
429            let bytes = read(&file);
430            let page = self.pages.entry(route.to_string()).or_default();
431            let unchanged = page
432                .current
433                .as_ref()
434                .is_some_and(|(loaded_from, loaded, _)| {
435                    *loaded_from == file && Some(loaded) == bytes.as_ref()
436                });
437            if unchanged {
438                // The build wrote back what is served: a later refusal of
439                // the same broken bytes is news again.
440                page.refused = None;
441                page.manifest = Some(Arc::clone(&self.accepted_manifest));
442                return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
443            }
444            let attempt = (file.clone(), bytes.as_deref().map(<[u8]>::to_vec));
445            if page.refused.as_ref() == Some(&attempt) {
446                // Refused and logged already; still the previous module.
447                return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
448            }
449            match check_module(bytes.as_deref()) {
450                Ok(module) => {
451                    tracing::info!(
452                        route,
453                        ir_file = %file,
454                        "Reloaded the IR module the build rewrote (development)"
455                    );
456                    page.current = Some((file, bytes.unwrap_or_default(), Arc::new(module)));
457                    page.manifest = Some(Arc::clone(&self.accepted_manifest));
458                    page.refused = None;
459                }
460                Err(refusal) => {
461                    let error = refusal.error(route, &file);
462                    tracing::warn!(
463                        code = error.code().unwrap_or_default(),
464                        route,
465                        ir_file = %file,
466                        "{error}; the previous module stays until the build writes one that \
467                         loads (development reload)"
468                    );
469                    page.refused = Some(attempt);
470                }
471            }
472            page.current.as_ref().map(|(_, _, m)| Arc::clone(m))
473        }
474
475        pub(super) fn manifest_for(&self, route: &str) -> Option<Arc<AssetManifest>> {
476            self.pages.get(route)?.manifest.clone()
477        }
478
479        fn current(&self, route: &str) -> Option<Arc<IrModule>> {
480            self.pages
481                .get(route)
482                .and_then(|p| p.current.as_ref())
483                .map(|(_, _, m)| Arc::clone(m))
484        }
485
486        /// Take the module names from `manifest.json` when it changed. One the
487        /// watch mode publishes is whole (it is replaced by rename); one that
488        /// does not validate (or disappears) freezes module reload as well as
489        /// the names/assets. Each distinct refusal is logged once. Restoring
490        /// valid bytes reopens reload, including the exact old manifest bytes.
491        fn reread_manifest(&mut self, read: Read<'_>) -> bool {
492            let Some(bytes) = read("manifest.json") else {
493                if self.manifest.is_some() || !self.manifest_rejected {
494                    tracing::warn!("manifest.json is missing; the previous module and assets stay together (development reload)");
495                }
496                self.manifest = None;
497                self.manifest_rejected = true;
498                return false;
499            };
500            if self.manifest.as_deref() == Some(&bytes[..]) {
501                return !self.manifest_rejected;
502            }
503            self.manifest_rejected = true;
504            match serde_json::from_slice::<AssetManifest>(&bytes) {
505                Ok(manifest)
506                    if self.frozen_topology && topology(&manifest) != self.initial_topology =>
507                {
508                    tracing::warn!(code = "BIND-112", "route names or modes changed after finalization; restart and bind the changed routes; previous module and manifest stay (development reload)");
509                }
510                Ok(manifest) => {
511                    self.manifest_rejected = false;
512                    self.accepted_manifest = Arc::new(manifest.clone());
513                    self.names = manifest
514                        .routes
515                        .into_iter()
516                        .filter_map(|(route, assets)| assets.ir.map(|file| (route, file)))
517                        .collect();
518                    // A valid explicit browser mode removes SSR. Invalid manifests
519                    // above leave every prior name/module untouched.
520                    self.pages.retain(|route, _| self.names.contains_key(route));
521                }
522                Err(e) => tracing::warn!(
523                    "manifest.json does not parse ({e}); the previous module and assets stay together \
524                     (development reload)"
525                ),
526            }
527            self.manifest = Some(bytes.into_owned());
528            !self.manifest_rejected
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::types::RouteAssets;
537    use rust_embed::Embed;
538
539    #[derive(Embed)]
540    #[folder = "test_fixtures/"]
541    struct Fixtures;
542
543    fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
544        let mut routes = HashMap::new();
545        routes.insert(
546            route.to_string(),
547            RouteAssets {
548                mode: if ir.is_some() {
549                    crate::RouteMode::Ssr
550                } else {
551                    crate::RouteMode::Browser
552                },
553                modulepreload: Vec::new(),
554                js: vec![],
555                css: vec![],
556                fonts: vec![],
557                total_size_br: 0,
558                budget_warn_threshold: 0,
559                ir: ir.map(str::to_string),
560            },
561        );
562        AssetManifest {
563            version: 2,
564            writer: "3.0.0".into(),
565            app: "ledger".into(),
566            aliases: HashMap::new(),
567            build: "test".to_string(),
568            assets: ir
569                .into_iter()
570                .map(|f| {
571                    (
572                        f.to_string(),
573                        crate::AssetMetadata {
574                            immutable: false,
575                            integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb".into(),
576                            retained: false,
577                        },
578                    )
579                })
580                .collect(),
581            routes,
582        }
583    }
584
585    #[test]
586    fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
587        // Previously this fell through with no log line and no render_modes
588        // entry — indistinguishable from a route that never had IR.
589        let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
590        let errors = load_ir_modules::<Fixtures>(&manifest).unwrap_err();
591        assert_eq!(errors.len(), 1);
592        assert_eq!(errors[0].route, "/");
593        assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
594        assert_eq!(errors[0].code(), Some("LOAD-101"));
595    }
596
597    #[test]
598    fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
599        let error = |reason: &str| IrLoadError {
600            route: "/".to_string(),
601            ir_file: "page.ir".to_string(),
602            reason: reason.to_string(),
603        };
604        assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
605        assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
606        // Built by hand without one, or something code-shaped that is not.
607        for reason in [
608            "bad magic",
609            "",
610            "LOAD-1 x",
611            "LOAD-1023 x",
612            "LOAD-10x y",
613            "load-102 x",
614        ] {
615            assert_eq!(error(reason).code(), None, "{reason:?}");
616        }
617        // The parser's errors map to their family.
618        assert_eq!(
619            parse_failure_code(&forma_ir::IrError::BadSignature(*b"INVALID!")),
620            "LOAD-102"
621        );
622        assert_eq!(
623            parse_failure_code(&forma_ir::IrError::LegacyModule { version: 2 }),
624            "LOAD-102"
625        );
626        assert_eq!(
627            parse_failure_code(&forma_ir::IrError::ReservedBits { field: "header" }),
628            "LOAD-103"
629        );
630        assert_eq!(
631            parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
632            "LOAD-103"
633        );
634    }
635
636    #[test]
637    fn a_route_with_no_ir_field_is_not_an_error() {
638        // The distinction the old code could not express.
639        let manifest = manifest_with_ir("/", None);
640        let pages = load_ir_modules::<Fixtures>(&manifest).expect("must not error");
641        pages.finalize().unwrap();
642    }
643}
644
645/// [`IrModules`]' reload, over files a test controls: the reader is the
646/// file-system boundary rust-embed reads in a debug build.
647#[cfg(test)]
648mod reload_tests {
649    use super::*;
650    use std::sync::atomic::{AtomicUsize, Ordering};
651    use std::sync::Mutex;
652
653    /// The files rust-embed would read, and how many reads it was asked for.
654    #[derive(Default)]
655    struct Disk {
656        files: Mutex<HashMap<String, Vec<u8>>>,
657        reads: AtomicUsize,
658    }
659
660    impl Disk {
661        fn write(&self, name: &str, bytes: Vec<u8>) {
662            self.files
663                .lock()
664                .expect("lock")
665                .insert(name.to_string(), bytes);
666        }
667        fn remove(&self, name: &str) {
668            self.files.lock().expect("lock").remove(name);
669        }
670        fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
671            self.reads.fetch_add(1, Ordering::SeqCst);
672            self.files
673                .lock()
674                .expect("lock")
675                .get(name)
676                .cloned()
677                .map(Cow::Owned)
678        }
679        fn reads(&self) -> usize {
680            self.reads.load(Ordering::SeqCst)
681        }
682    }
683
684    /// A module rendering `<p>{text}</p>`.
685    fn page(text: &str) -> Vec<u8> {
686        use forma_ir::parser::test_helpers::{
687            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
688        };
689        let mut ops = encode_open_tag(0, &[]);
690        ops.extend_from_slice(&encode_text(1));
691        ops.extend_from_slice(&encode_close_tag(0));
692        build_minimal_ir(&["p", text], &[], &ops, &[])
693    }
694
695    fn html(module: Option<Arc<IrModule>>) -> String {
696        let module = module.expect("a module");
697        forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
698    }
699
700    /// The manifest JSON naming each route's module (`None`: no module).
701    fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
702        let routes: serde_json::Map<String, serde_json::Value> = routes
703            .iter()
704            .map(|(route, ir)| {
705                let mut assets = serde_json::json!({
706                    "mode": if ir.is_some(){"ssr"}else{"browser"},
707                    "js": ["app.js"], "css": [], "fonts": [],
708                    "total_size_br": 0, "budget_warn_threshold": 0
709                });
710                if let Some(ir) = ir {
711                    assets["ir"] = (*ir).into();
712                }
713                (route.to_string(), assets)
714            })
715            .collect();
716        let mut assets = serde_json::Map::new();
717        assets.insert("app.js".into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
718        for route in routes.values() {
719            if let Some(ir) = route.get("ir").and_then(|v| v.as_str()) {
720                assets.insert(ir.into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
721            }
722        }
723        serde_json::json!({"version": 2, "writer":"3.0.0", "app":"ledger", "build": "dev", "aliases":{}, "assets": assets, "routes": routes})
724            .to_string()
725            .into_bytes()
726    }
727
728    /// The disk with `/` naming `page.ir` and `/app` naming none, and the
729    /// manifest the application read from it at start.
730    fn started(first: &str) -> (Disk, AssetManifest) {
731        let disk = Disk::default();
732        let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
733        disk.write("manifest.json", json.clone());
734        disk.write("page.ir", page(first));
735        let manifest = serde_json::from_slice(&json).expect("manifest");
736        (disk, manifest)
737    }
738
739    #[test]
740    fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
741        let (disk, manifest) = started("first");
742        let read = |name: &str| disk.read(name);
743        let modules = Modules::load(&manifest, &read).expect("loads");
744        let after_load = disk.reads();
745        assert!(after_load >= 1, "the start reads the module");
746
747        for _ in 0..3 {
748            assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
749        }
750        assert!(
751            modules.get("/app", &read).is_none(),
752            "a route with no module"
753        );
754        // The build rewrites the page's module.
755        disk.write("page.ir", page("second"));
756        let served = html(modules.get("/", &read));
757
758        if cfg!(debug_assertions) {
759            assert_eq!(
760                served, "<p>second</p>",
761                "debug: the next get serves the rewrite"
762            );
763            // Each get reads the manifest and the route's module, if it has one.
764            assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
765        } else {
766            assert_eq!(
767                served, "<p>first</p>",
768                "release: the module loaded at start"
769            );
770            assert_eq!(disk.reads(), after_load, "release: no read after start");
771        }
772    }
773
774    #[test]
775    fn the_reload_loads_through_the_strict_loader_at_start() {
776        // A bad module at start is a refused start, in every build, with the
777        // strict loader's codes.
778        let (disk, manifest) = started("first");
779        let mut flipped = page("first");
780        flipped[0] ^= 0xFF;
781        disk.write("page.ir", flipped);
782        let read = |name: &str| disk.read(name);
783        let errors = Modules::load(&manifest, &read).err().expect("refused");
784        assert_eq!(errors.len(), 1);
785        assert_eq!(errors[0].route, "/");
786        assert_eq!(errors[0].ir_file, "page.ir");
787        assert_eq!(errors[0].code(), Some("LOAD-102"));
788        disk.remove("page.ir");
789        let errors = Modules::load(&manifest, &read).err().expect("refused");
790        assert_eq!(errors[0].code(), Some("LOAD-101"));
791    }
792
793    #[cfg(debug_assertions)]
794    mod development {
795        use super::*;
796        use tracing::field::{Field, Visit};
797        use tracing::span::{Attributes, Id, Record};
798        use tracing::{Event, Metadata, Subscriber};
799
800        /// The warnings logged, as (code, message).
801        #[derive(Clone, Default)]
802        struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
803
804        impl Subscriber for Warnings {
805            fn enabled(&self, _: &Metadata<'_>) -> bool {
806                true
807            }
808            fn new_span(&self, _: &Attributes<'_>) -> Id {
809                Id::from_u64(1)
810            }
811            fn record(&self, _: &Id, _: &Record<'_>) {}
812            fn record_follows_from(&self, _: &Id, _: &Id) {}
813            fn event(&self, event: &Event<'_>) {
814                #[derive(Default)]
815                struct Fields(String, String);
816                impl Visit for Fields {
817                    fn record_str(&mut self, field: &Field, value: &str) {
818                        if field.name() == "code" {
819                            self.0 = value.to_string();
820                        }
821                    }
822                    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
823                        if field.name() == "message" {
824                            self.1 = format!("{value:?}");
825                        }
826                    }
827                }
828                if *event.metadata().level() == tracing::Level::WARN {
829                    let mut fields = Fields::default();
830                    event.record(&mut fields);
831                    self.0.lock().expect("lock").push((fields.0, fields.1));
832                }
833            }
834            fn enter(&self, _: &Id) {}
835            fn exit(&self, _: &Id) {}
836        }
837
838        impl Warnings {
839            fn codes(&self) -> Vec<String> {
840                self.0
841                    .lock()
842                    .expect("lock")
843                    .iter()
844                    .map(|(c, _)| c.clone())
845                    .collect()
846            }
847        }
848
849        #[test]
850        fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
851            let (disk, manifest) = started("first");
852            let read = |name: &str| disk.read(name);
853            let modules = Modules::load(&manifest, &read).expect("loads");
854            let warnings = Warnings::default();
855            tracing::subscriber::with_default(warnings.clone(), || {
856                // A truncated module: refused, the previous one served, one
857                // warning however many requests read it.
858                let good = page("second");
859                disk.write("page.ir", good[..good.len() - 3].to_vec());
860                for _ in 0..3 {
861                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
862                }
863                assert_eq!(warnings.codes(), ["LOAD-104"]);
864                let (_, message) = warnings.0.lock().expect("lock")[0].clone();
865                assert!(
866                    message.contains("route /: IR file page.ir: LOAD-104"),
867                    "{message}"
868                );
869                assert!(message.contains("the previous module stays"), "{message}");
870
871                // Not a module at all (bad magic): its own code, once.
872                let mut magic = page("second");
873                magic[0] ^= 0xFF;
874                disk.write("page.ir", magic.clone());
875                for _ in 0..3 {
876                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
877                }
878                // Deleted: LOAD-101, once.
879                disk.remove("page.ir");
880                for _ in 0..3 {
881                    assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
882                }
883                assert_eq!(warnings.codes(), ["LOAD-104", "LOAD-102", "LOAD-101"]);
884
885                // The next good write is served...
886                disk.write("page.ir", page("second"));
887                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
888                // ...and breaking it the same way again is news again.
889                disk.write("page.ir", magic.clone());
890                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
891                assert_eq!(
892                    warnings.codes(),
893                    ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102"]
894                );
895                // So is the same break after the build wrote back the module
896                // being served (an undo), which reloads nothing.
897                disk.write("page.ir", page("second"));
898                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
899                disk.write("page.ir", magic);
900                assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
901                assert_eq!(
902                    warnings.codes(),
903                    ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
904                );
905            });
906        }
907
908        #[test]
909        fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
910            let (disk, manifest) = started("first");
911            let read = |name: &str| disk.read(name);
912            let modules = Modules::load(&manifest, &read).expect("loads");
913            assert!(modules.get("/late", &read).is_none());
914            let warnings = Warnings::default();
915            tracing::subscriber::with_default(warnings.clone(), || {
916                // A rebuilt production manifest: new names for both pages,
917                // and a route that had no module now has one.
918                disk.write("page.2.ir", page("renamed"));
919                disk.write("late.ir", page("late"));
920                disk.write(
921                    "manifest.json",
922                    manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
923                );
924                assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
925                assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
926                assert!(warnings.codes().is_empty());
927
928                // A manifest that does not parse: logged once, names kept.
929                disk.write("manifest.json", b"{\"routes\":".to_vec());
930                for _ in 0..3 {
931                    assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
932                }
933                let logged = warnings.0.lock().expect("lock").clone();
934                assert_eq!(logged.len(), 1, "{logged:?}");
935                assert!(
936                    logged[0].1.contains("manifest.json does not parse"),
937                    "{logged:?}"
938                );
939
940                // Valid JSON with an incompatible writer is refused by the
941                // same validator as startup, retaining the last good modules.
942                let mut incompatible: serde_json::Value =
943                    serde_json::from_slice(&manifest_json(&[("/", Some("page.2.ir"))])).unwrap();
944                incompatible["writer"] = "3.1.0".into();
945                disk.write("manifest.json", incompatible.to_string().into_bytes());
946                assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
947                assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
948                assert!(warnings
949                    .0
950                    .lock()
951                    .unwrap()
952                    .last()
953                    .unwrap()
954                    .1
955                    .contains("MANIFEST-102"));
956
957                // A valid explicit browser route removes SSR; a removed route
958                // is no longer served from the previous module either.
959                disk.write("manifest.json", manifest_json(&[("/", None)]));
960                assert!(modules.get("/", &read).is_none());
961                assert!(modules.get("/late", &read).is_none());
962            });
963        }
964    }
965}