use forma_ir::parser::IrModule;
use rust_embed::Embed;
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::marker::PhantomData;
use std::sync::Arc;
use crate::{AssetManifest, RenderMode};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IrLoadError {
pub route: String,
pub ir_file: String,
pub reason: String,
}
impl IrLoadError {
pub fn code(&self) -> Option<&str> {
let code = self.reason.split(' ').next()?;
let digits = code.strip_prefix("LOAD-")?;
(digits.len() == 3 && digits.bytes().all(|b| b.is_ascii_digit())).then_some(code)
}
}
fn parse_failure_code(error: &forma_ir::IrError) -> &'static str {
use forma_ir::IrError;
match error {
IrError::BadMagic(_) | IrError::UnsupportedVersion(_) | IrError::UnsupportedFlags(_) => {
"LOAD-102"
}
_ => "LOAD-103",
}
}
impl std::fmt::Display for IrLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"route {}: IR file {}: {}",
self.route, self.ir_file, self.reason
)
}
}
impl std::error::Error for IrLoadError {}
type Read<'r> = &'r dyn Fn(&str) -> Option<Cow<'static, [u8]>>;
enum Refusal {
NotEmbedded,
Parse(forma_ir::IrError),
}
impl Refusal {
fn error(&self, route: &str, ir_file: &str) -> IrLoadError {
let reason = match self {
Refusal::NotEmbedded => {
"LOAD-101 declared in the manifest but not embedded".to_string()
}
Refusal::Parse(e) => format!("{} {e}", parse_failure_code(e)),
};
IrLoadError {
route: route.to_string(),
ir_file: ir_file.to_string(),
reason,
}
}
}
fn check_module(bytes: Option<&[u8]>) -> Result<IrModule, Refusal> {
let bytes = bytes.ok_or(Refusal::NotEmbedded)?;
IrModule::parse(bytes).map_err(Refusal::Parse)
}
struct Loaded {
route: String,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
ir_file: String,
#[cfg_attr(not(debug_assertions), allow(dead_code))]
bytes: Cow<'static, [u8]>,
module: IrModule,
}
fn load_from(
manifest: &AssetManifest,
read: Read<'_>,
log_fallbacks: bool,
) -> (HashMap<String, RenderMode>, Vec<Loaded>, Vec<IrLoadError>) {
let mut render_modes = HashMap::new();
let mut loaded = Vec::new();
let mut errors = Vec::new();
for (route_pattern, route_assets) in &manifest.routes {
let Some(ref ir_filename) = route_assets.ir else {
continue;
};
let bytes = read(ir_filename);
match check_module(bytes.as_deref()) {
Ok(module) => {
tracing::info!(
route = %route_pattern,
ir_file = %ir_filename,
"Loaded IR module for SSR"
);
render_modes.insert(route_pattern.clone(), RenderMode::Phase2SsrReconcile);
loaded.push(Loaded {
route: route_pattern.clone(),
ir_file: ir_filename.clone(),
bytes: bytes.unwrap_or_default(),
module,
});
}
Err(refusal) => {
if log_fallbacks {
match &refusal {
Refusal::NotEmbedded => tracing::warn!(
route = %route_pattern,
ir_file = %ir_filename,
"Manifest declares an IR file that is not embedded — Phase 1 fallback"
),
Refusal::Parse(e) => tracing::warn!(
route = %route_pattern,
ir_file = %ir_filename,
error = %e,
"Failed to parse IR module — Phase 1 fallback"
),
}
}
errors.push(refusal.error(route_pattern, ir_filename));
render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
}
}
}
if !loaded.is_empty() {
tracing::info!(
ssr_routes = loaded.len(),
"Phase 2 SSR enabled for {} route(s)",
loaded.len()
);
}
(render_modes, loaded, errors)
}
fn load_ir_modules_inner<A: Embed>(
manifest: &AssetManifest,
log_fallbacks: bool,
) -> (
HashMap<String, RenderMode>,
HashMap<String, IrModule>,
Vec<IrLoadError>,
) {
let (render_modes, loaded, errors) = load_from(manifest, &embedded::<A>, log_fallbacks);
let ir_modules = loaded.into_iter().map(|l| (l.route, l.module)).collect();
(render_modes, ir_modules, errors)
}
fn embedded<A: Embed>(name: &str) -> Option<Cow<'static, [u8]>> {
A::get(name).map(|file| file.data)
}
#[deprecated(
since = "0.2.2",
note = "serves a corrupt module's route as an empty shell for the life of the process; \
use `load_ir_modules_strict`, the default loader, and refuse to start on its \
errors. Removed in 3.0.0"
)]
pub fn load_ir_modules<A: Embed>(
manifest: &AssetManifest,
) -> (HashMap<String, RenderMode>, HashMap<String, IrModule>) {
log_deprecation_once();
let (render_modes, ir_modules, _errors) = load_ir_modules_inner::<A>(manifest, true);
(render_modes, ir_modules)
}
fn log_deprecation_once() {
#[cfg(debug_assertions)]
{
use std::sync::atomic::{AtomicBool, Ordering};
static LOGGED: AtomicBool = AtomicBool::new(false);
if !LOGGED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"forma_server::load_ir_modules is deprecated: it serves a corrupt module's route \
as an empty shell; use load_ir_modules_strict and refuse to start on its errors. \
Logged once per process, in debug builds."
);
}
}
}
#[allow(clippy::type_complexity)]
pub fn load_ir_modules_strict<A: Embed>(
manifest: &AssetManifest,
) -> Result<(HashMap<String, RenderMode>, HashMap<String, IrModule>), Vec<IrLoadError>> {
let (render_modes, ir_modules, errors) = load_ir_modules_inner::<A>(manifest, false);
if errors.is_empty() {
Ok((render_modes, ir_modules))
} else {
Err(errors)
}
}
pub struct IrModules<A> {
modules: Modules,
assets: PhantomData<fn() -> A>,
}
impl<A: Embed> IrModules<A> {
pub fn load(manifest: &AssetManifest) -> Result<Self, Vec<IrLoadError>> {
Ok(IrModules {
modules: Modules::load(manifest, &embedded::<A>)?,
assets: PhantomData,
})
}
pub fn get(&self, route_pattern: &str) -> Option<Arc<IrModule>> {
self.modules.get(route_pattern, &embedded::<A>)
}
}
impl<A> fmt::Debug for IrModules<A> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IrModules")
.field("routes", &self.modules.routes())
.finish()
}
}
struct Modules {
#[cfg(not(debug_assertions))]
by_route: HashMap<String, Arc<IrModule>>,
#[cfg(debug_assertions)]
dev: std::sync::Mutex<dev::Pages>,
}
impl Modules {
fn load(manifest: &AssetManifest, read: Read<'_>) -> Result<Self, Vec<IrLoadError>> {
let (_, loaded, errors) = load_from(manifest, read, false);
if !errors.is_empty() {
return Err(errors);
}
#[cfg(not(debug_assertions))]
let modules = Modules {
by_route: loaded
.into_iter()
.map(|l| (l.route, Arc::new(l.module)))
.collect(),
};
#[cfg(debug_assertions)]
let modules = Modules {
dev: std::sync::Mutex::new(dev::Pages::new(manifest, loaded, read)),
};
Ok(modules)
}
#[cfg(not(debug_assertions))]
fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
let _ = read;
self.by_route.get(route).cloned()
}
#[cfg(debug_assertions)]
fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
pages.get(route, read)
}
fn routes(&self) -> Vec<String> {
#[cfg(not(debug_assertions))]
let mut routes: Vec<String> = self.by_route.keys().cloned().collect();
#[cfg(debug_assertions)]
let mut routes = self.dev.lock().unwrap_or_else(|p| p.into_inner()).routes();
routes.sort();
routes
}
}
#[cfg(debug_assertions)]
mod dev {
use super::{check_module, Loaded, Read};
use crate::AssetManifest;
use forma_ir::parser::IrModule;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
pub(super) struct Pages {
manifest: Option<Vec<u8>>,
names: HashMap<String, String>,
pages: HashMap<String, Page>,
}
#[derive(Default)]
struct Page {
current: Option<(String, Cow<'static, [u8]>, Arc<IrModule>)>,
refused: Option<(String, Option<Vec<u8>>)>,
}
impl Pages {
pub(super) fn new(manifest: &AssetManifest, loaded: Vec<Loaded>, read: Read<'_>) -> Self {
let names = manifest
.routes
.iter()
.filter_map(|(route, assets)| Some((route.clone(), assets.ir.clone()?)))
.collect();
let pages = loaded
.into_iter()
.map(|l| {
let page = Page {
current: Some((l.ir_file, l.bytes, Arc::new(l.module))),
refused: None,
};
(l.route, page)
})
.collect();
Pages {
manifest: read("manifest.json").map(Cow::into_owned),
names,
pages,
}
}
pub(super) fn routes(&self) -> Vec<String> {
self.pages
.iter()
.filter(|(_, p)| p.current.is_some())
.map(|(r, _)| r.clone())
.collect()
}
pub(super) fn get(&mut self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
self.reread_manifest(read);
let Some(file) = self.names.get(route).cloned() else {
return self.current(route);
};
let bytes = read(&file);
let page = self.pages.entry(route.to_string()).or_default();
let unchanged = page
.current
.as_ref()
.is_some_and(|(loaded_from, loaded, _)| {
*loaded_from == file && Some(loaded) == bytes.as_ref()
});
if unchanged {
page.refused = None;
return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
}
let attempt = (file.clone(), bytes.as_deref().map(<[u8]>::to_vec));
if page.refused.as_ref() == Some(&attempt) {
return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
}
match check_module(bytes.as_deref()) {
Ok(module) => {
tracing::info!(
route,
ir_file = %file,
"Reloaded the IR module the build rewrote (development)"
);
page.current = Some((file, bytes.unwrap_or_default(), Arc::new(module)));
page.refused = None;
}
Err(refusal) => {
let error = refusal.error(route, &file);
tracing::warn!(
code = error.code().unwrap_or_default(),
route,
ir_file = %file,
"{error}; the previous module stays until the build writes one that \
loads (development reload)"
);
page.refused = Some(attempt);
}
}
page.current.as_ref().map(|(_, _, m)| Arc::clone(m))
}
fn current(&self, route: &str) -> Option<Arc<IrModule>> {
self.pages
.get(route)
.and_then(|p| p.current.as_ref())
.map(|(_, _, m)| Arc::clone(m))
}
fn reread_manifest(&mut self, read: Read<'_>) {
let Some(bytes) = read("manifest.json") else {
return;
};
if self.manifest.as_deref() == Some(&bytes[..]) {
return;
}
match serde_json::from_slice::<AssetManifest>(&bytes) {
Ok(manifest) => {
for (route, assets) in manifest.routes {
if let Some(ir) = assets.ir {
self.names.insert(route, ir);
}
}
}
Err(e) => tracing::warn!(
"manifest.json does not parse ({e}); the module names stay as they were \
(development reload)"
),
}
self.manifest = Some(bytes.into_owned());
}
}
}
#[cfg(test)]
#[allow(deprecated)] mod tests {
use super::*;
use crate::types::RouteAssets;
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "test_fixtures/"]
struct Fixtures;
fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
let mut routes = HashMap::new();
routes.insert(
route.to_string(),
RouteAssets {
js: vec![],
css: vec![],
fonts: vec![],
total_size_br: 0,
budget_warn_threshold: 0,
ir: ir.map(str::to_string),
},
);
AssetManifest {
version: 1,
build_hash: "test".to_string(),
assets: HashMap::new(),
routes,
wasm: None,
}
}
#[test]
fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
let errors = load_ir_modules_strict::<Fixtures>(&manifest).unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].route, "/");
assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
assert_eq!(errors[0].code(), Some("LOAD-101"));
let (render_modes, ir_modules) = load_ir_modules::<Fixtures>(&manifest);
assert_eq!(render_modes.get("/"), Some(&RenderMode::Phase1ClientMount));
assert!(ir_modules.is_empty());
}
#[test]
fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
let error = |reason: &str| IrLoadError {
route: "/".to_string(),
ir_file: "page.ir".to_string(),
reason: reason.to_string(),
};
assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
for reason in [
"bad magic",
"",
"LOAD-1 x",
"LOAD-1023 x",
"LOAD-10x y",
"load-102 x",
] {
assert_eq!(error(reason).code(), None, "{reason:?}");
}
assert_eq!(
parse_failure_code(&forma_ir::IrError::BadMagic(*b"GMIR")),
"LOAD-102"
);
assert_eq!(
parse_failure_code(&forma_ir::IrError::UnsupportedVersion(3)),
"LOAD-102"
);
assert_eq!(
parse_failure_code(&forma_ir::IrError::UnsupportedFlags(1)),
"LOAD-102"
);
assert_eq!(
parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
"LOAD-103"
);
}
#[test]
fn a_route_with_no_ir_field_is_not_an_error() {
let manifest = manifest_with_ir("/", None);
let (render_modes, ir_modules) =
load_ir_modules_strict::<Fixtures>(&manifest).expect("must not error");
assert!(render_modes.is_empty());
assert!(ir_modules.is_empty());
}
}
#[cfg(test)]
mod reload_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
#[derive(Default)]
struct Disk {
files: Mutex<HashMap<String, Vec<u8>>>,
reads: AtomicUsize,
}
impl Disk {
fn write(&self, name: &str, bytes: Vec<u8>) {
self.files
.lock()
.expect("lock")
.insert(name.to_string(), bytes);
}
fn remove(&self, name: &str) {
self.files.lock().expect("lock").remove(name);
}
fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
self.reads.fetch_add(1, Ordering::SeqCst);
self.files
.lock()
.expect("lock")
.get(name)
.cloned()
.map(Cow::Owned)
}
fn reads(&self) -> usize {
self.reads.load(Ordering::SeqCst)
}
}
fn page(text: &str) -> Vec<u8> {
use forma_ir::parser::test_helpers::{
build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
};
let mut ops = encode_open_tag(0, &[]);
ops.extend_from_slice(&encode_text(1));
ops.extend_from_slice(&encode_close_tag(0));
build_minimal_ir(&["p", text], &[], &ops, &[])
}
fn html(module: Option<Arc<IrModule>>) -> String {
let module = module.expect("a module");
forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
}
fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
let routes: serde_json::Map<String, serde_json::Value> = routes
.iter()
.map(|(route, ir)| {
let mut assets = serde_json::json!({
"js": ["app.js"], "css": [], "fonts": [],
"total_size_br": 0, "budget_warn_threshold": 0
});
if let Some(ir) = ir {
assets["ir"] = (*ir).into();
}
(route.to_string(), assets)
})
.collect();
serde_json::json!({"version": 1, "build_hash": "dev", "assets": {}, "routes": routes})
.to_string()
.into_bytes()
}
fn started(first: &str) -> (Disk, AssetManifest) {
let disk = Disk::default();
let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
disk.write("manifest.json", json.clone());
disk.write("page.ir", page(first));
let manifest = serde_json::from_slice(&json).expect("manifest");
(disk, manifest)
}
#[test]
fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
let (disk, manifest) = started("first");
let read = |name: &str| disk.read(name);
let modules = Modules::load(&manifest, &read).expect("loads");
let after_load = disk.reads();
assert!(after_load >= 1, "the start reads the module");
for _ in 0..3 {
assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
}
assert!(
modules.get("/app", &read).is_none(),
"a route with no module"
);
disk.write("page.ir", page("second"));
let served = html(modules.get("/", &read));
if cfg!(debug_assertions) {
assert_eq!(
served, "<p>second</p>",
"debug: the next get serves the rewrite"
);
assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
} else {
assert_eq!(
served, "<p>first</p>",
"release: the module loaded at start"
);
assert_eq!(disk.reads(), after_load, "release: no read after start");
}
}
#[test]
fn the_reload_loads_through_the_strict_loader_at_start() {
let (disk, manifest) = started("first");
let mut flipped = page("first");
flipped[0] ^= 0xFF;
disk.write("page.ir", flipped);
let read = |name: &str| disk.read(name);
let errors = Modules::load(&manifest, &read).err().expect("refused");
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].route, "/");
assert_eq!(errors[0].ir_file, "page.ir");
assert_eq!(errors[0].code(), Some("LOAD-102"));
disk.remove("page.ir");
let errors = Modules::load(&manifest, &read).err().expect("refused");
assert_eq!(errors[0].code(), Some("LOAD-101"));
}
#[cfg(debug_assertions)]
mod development {
use super::*;
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Metadata, Subscriber};
#[derive(Clone, Default)]
struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
impl Subscriber for Warnings {
fn enabled(&self, _: &Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _: &Id, _: &Record<'_>) {}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, event: &Event<'_>) {
#[derive(Default)]
struct Fields(String, String);
impl Visit for Fields {
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "code" {
self.0 = value.to_string();
}
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.1 = format!("{value:?}");
}
}
}
if *event.metadata().level() == tracing::Level::WARN {
let mut fields = Fields::default();
event.record(&mut fields);
self.0.lock().expect("lock").push((fields.0, fields.1));
}
}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
impl Warnings {
fn codes(&self) -> Vec<String> {
self.0
.lock()
.expect("lock")
.iter()
.map(|(c, _)| c.clone())
.collect()
}
}
#[test]
fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
let (disk, manifest) = started("first");
let read = |name: &str| disk.read(name);
let modules = Modules::load(&manifest, &read).expect("loads");
let warnings = Warnings::default();
tracing::subscriber::with_default(warnings.clone(), || {
let good = page("second");
disk.write("page.ir", good[..good.len() - 3].to_vec());
for _ in 0..3 {
assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
}
assert_eq!(warnings.codes(), ["LOAD-103"]);
let (_, message) = warnings.0.lock().expect("lock")[0].clone();
assert!(
message.contains("route /: IR file page.ir: LOAD-103"),
"{message}"
);
assert!(message.contains("the previous module stays"), "{message}");
let mut magic = page("second");
magic[0] ^= 0xFF;
disk.write("page.ir", magic.clone());
for _ in 0..3 {
assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
}
disk.remove("page.ir");
for _ in 0..3 {
assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
}
assert_eq!(warnings.codes(), ["LOAD-103", "LOAD-102", "LOAD-101"]);
disk.write("page.ir", page("second"));
assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
disk.write("page.ir", magic.clone());
assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
assert_eq!(
warnings.codes(),
["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102"]
);
disk.write("page.ir", page("second"));
assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
disk.write("page.ir", magic);
assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
assert_eq!(
warnings.codes(),
["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
);
});
}
#[test]
fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
let (disk, manifest) = started("first");
let read = |name: &str| disk.read(name);
let modules = Modules::load(&manifest, &read).expect("loads");
assert!(modules.get("/late", &read).is_none());
let warnings = Warnings::default();
tracing::subscriber::with_default(warnings.clone(), || {
disk.write("page.2.ir", page("renamed"));
disk.write("late.ir", page("late"));
disk.write(
"manifest.json",
manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
);
assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
assert!(warnings.codes().is_empty());
disk.write("manifest.json", b"{\"routes\":".to_vec());
for _ in 0..3 {
assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
}
let logged = warnings.0.lock().expect("lock").clone();
assert_eq!(logged.len(), 1, "{logged:?}");
assert!(
logged[0].1.contains("manifest.json does not parse"),
"{logged:?}"
);
disk.write("manifest.json", manifest_json(&[("/", None)]));
assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
});
}
}
}