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