use crate::csp::{self, CspNonce, NonceError, PolicyError, PolicyExtensions};
use crate::head::{HeadLink, ModulePreloads};
use crate::response::{PageResponse, RenderCounts};
use crate::types::PageConfig;
use forma_ir::{IrError, WalkLimits};
use std::fmt;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct RenderCtx {
policy: PolicyExtensions,
limits: Option<WalkLimits>,
module_preloads: Option<ModulePreloads>,
head_links: Vec<HeadLink>,
title: Option<String>,
body_class: Option<String>,
config_json: Option<serde_json::Value>,
personality_css: Option<String>,
body_prefix: Option<crate::RawHtml>,
}
impl RenderCtx {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_body_class(mut self, class: impl Into<String>) -> Self {
self.body_class = Some(class.into());
self
}
#[must_use]
pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
self.config_json = Some(value);
self
}
#[must_use]
pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
self.personality_css = Some(css.into());
self
}
#[must_use]
pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
self.body_prefix = Some(html);
self
}
pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
self.config_json.as_ref()
}
pub(crate) fn personality_css(&self) -> Option<&str> {
self.personality_css.as_deref()
}
pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
self.body_prefix.as_ref()
}
pub(crate) fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub(crate) fn body_class(&self) -> Option<&str> {
self.body_class.as_deref()
}
#[must_use]
pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn with_limits(mut self, limits: WalkLimits) -> Self {
self.limits = Some(limits);
self
}
pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
self.limits.unwrap_or(inherited)
}
#[must_use]
pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
self.module_preloads = Some(preloads.clone());
self
}
#[must_use]
pub fn with_head_link(mut self, link: HeadLink) -> Self {
self.head_links.push(link);
self
}
pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
self.module_preloads
.as_ref()
.map_or(&[], |table| table.route(route_pattern))
}
pub(crate) fn head_links(&self) -> &[HeadLink] {
&self.head_links
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RenderError {
#[non_exhaustive]
StaticRoute {
route: String,
message: String,
},
#[non_exhaustive]
Manifest {
route: String,
message: String,
},
#[non_exhaustive]
RouteMode {
route: String,
},
#[non_exhaustive]
Unfinalized {
route: String,
},
#[non_exhaustive]
Props {
cause: forma_ir::props::PropsError,
},
#[non_exhaustive]
Binding {
route: String,
errors: Vec<forma_ir::props::PropsError>,
},
#[non_exhaustive]
MissingModule {
route: String,
},
#[non_exhaustive]
Walk {
route: String,
component_path: Vec<String>,
counts: RenderCounts,
cause: IrError,
},
#[non_exhaustive]
IslandState {
route: String,
component_path: Vec<String>,
counts: RenderCounts,
cause: IrError,
},
#[non_exhaustive]
Policy {
route: String,
cause: PolicyError,
},
#[non_exhaustive]
Nonce {
route: String,
cause: NonceError,
},
#[non_exhaustive]
Budget {
route: String,
component_path: Vec<String>,
counts: RenderCounts,
cause: IrError,
},
#[non_exhaustive]
Fault {
route: String,
component_path: Vec<String>,
counts: RenderCounts,
message: String,
},
}
impl RenderError {
pub fn code(&self) -> &'static str {
match self {
RenderError::StaticRoute { .. } => "RENDER-105",
RenderError::Manifest { .. } => "RENDER-104",
RenderError::RouteMode { .. } => "RENDER-103",
RenderError::Unfinalized { .. } => "RENDER-406",
RenderError::Props { cause } => cause.code(),
RenderError::Binding { .. } => "RENDER-405",
RenderError::MissingModule { .. } => "RENDER-101",
RenderError::Walk { cause, .. } => match cause {
IrError::InvalidListKey(_) => "RENDER-503",
_ => "RENDER-102",
},
RenderError::IslandState { cause, .. } => match cause {
IrError::IslandStateLimitExceeded { .. } => "RENDER-502",
IrError::InvalidIslandState(_) => "RENDER-501",
_ => "RENDER-501",
},
RenderError::Policy { .. } => "RENDER-201",
RenderError::Nonce { .. } => "RENDER-202",
RenderError::Budget { cause, .. } => match cause {
IrError::OutputLimitExceeded { .. } => "RENDER-301",
IrError::ListBudgetExceeded { .. } => "RENDER-302",
_ => "RENDER-303",
},
RenderError::Fault { .. } => "RENDER-399",
}
}
pub fn route(&self) -> &str {
match self {
RenderError::StaticRoute { route, .. }
| RenderError::Manifest { route, .. }
| RenderError::RouteMode { route }
| RenderError::Unfinalized { route } => route,
RenderError::Props { cause } => cause.route(),
RenderError::Binding { route, .. } => route,
RenderError::MissingModule { route }
| RenderError::Walk { route, .. }
| RenderError::IslandState { route, .. }
| RenderError::Policy { route, .. }
| RenderError::Nonce { route, .. }
| RenderError::Budget { route, .. }
| RenderError::Fault { route, .. } => route,
}
}
pub fn property_path(&self) -> Option<&str> {
match self {
Self::Props { cause } => Some(cause.path()),
Self::IslandState {
cause: IrError::InvalidIslandState(problem),
..
} => Some(&problem.path),
_ => None,
}
}
pub fn component_path(&self) -> &[String] {
match self {
RenderError::Walk { component_path, .. }
| RenderError::IslandState { component_path, .. }
| RenderError::Budget { component_path, .. }
| RenderError::Fault { component_path, .. } => component_path,
_ => &[],
}
}
pub fn counts(&self) -> Option<RenderCounts> {
match self {
RenderError::Walk { counts, .. }
| RenderError::IslandState { counts, .. }
| RenderError::Budget { counts, .. }
| RenderError::Fault { counts, .. } => Some(*counts),
_ => None,
}
}
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.code(), self.route())?;
let path = self.component_path();
if !path.is_empty() {
write!(f, " in {}", path.join(" > "))?;
}
match self {
RenderError::StaticRoute { message, .. } | RenderError::Manifest { message, .. } => {
write!(f, ": {message}")
}
RenderError::RouteMode { .. } => {
write!(f, ": route is not explicitly browser-rendered")
}
RenderError::Unfinalized { .. } => write!(
f,
": bind every SSR route and finalize Pages before serving"
),
RenderError::Props { cause } => write!(f, ": {cause}"),
RenderError::Binding { errors, .. } => {
for error in errors {
write!(f, "; {error}")?;
}
Ok(())
}
RenderError::MissingModule { .. } => {
write!(f, ": no module or slots to render on the server")
}
RenderError::Walk { cause, .. }
| RenderError::IslandState { cause, .. }
| RenderError::Budget { cause, .. } => {
write!(f, ": {cause}")
}
RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
}?;
if let Some(c) = self.counts() {
write!(
f,
" (after {} nodes, {} bytes, {} list rows)",
c.nodes, c.bytes, c.list_iterations
)?;
}
Ok(())
}
}
impl std::error::Error for RenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RenderError::Walk { cause, .. }
| RenderError::IslandState { cause, .. }
| RenderError::Budget { cause, .. } => Some(cause),
RenderError::Props { cause } => Some(cause),
RenderError::Binding { errors, .. } => {
errors.first().map(|error| error as &dyn std::error::Error)
}
RenderError::Policy { cause, .. } => Some(cause),
RenderError::Nonce { cause, .. } => Some(cause),
_ => None,
}
}
}
pub(crate) fn render_bound(
config: &PageConfig,
ctx: &RenderCtx,
module: Option<&forma_ir::IrModule>,
slots: Option<&forma_ir::SlotData>,
limits: WalkLimits,
) -> Result<PageResponse, RenderError> {
render_guarded(
config,
ctx,
CspNonce::try_generate,
Root::Walk {
module,
slots,
limits,
},
)
}
pub fn render_browser_shell(
config: &PageConfig,
ctx: &RenderCtx,
) -> Result<PageResponse, RenderError> {
if !config
.manifest
.route(config.route_pattern)
.is_some_and(|route| route.mode == crate::RouteMode::Browser)
{
return Err(RenderError::RouteMode {
route: config.route_pattern.into(),
});
}
config
.manifest
.validate()
.map_err(|message| RenderError::Manifest {
route: config.route_pattern.into(),
message,
})?;
render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
}
#[derive(Debug, Clone, Copy)]
enum Root<'a> {
Walk {
module: Option<&'a forma_ir::IrModule>,
slots: Option<&'a forma_ir::SlotData>,
limits: WalkLimits,
},
Shell,
}
fn render_guarded(
config: &PageConfig,
ctx: &RenderCtx,
draw: impl FnOnce() -> Result<CspNonce, NonceError>,
root: Root<'_>,
) -> Result<PageResponse, RenderError> {
guard(config.route_pattern, || render(config, ctx, draw, root))
}
pub(crate) fn guard(
route: &str,
render: impl FnOnce() -> Result<PageResponse, RenderError>,
) -> Result<PageResponse, RenderError> {
match catch_unwind(AssertUnwindSafe(render)) {
Ok(result) => result,
Err(payload) => Err(RenderError::Fault {
route: route.to_string(),
component_path: Vec::new(),
counts: RenderCounts::default(),
message: panic_message(payload.as_ref()),
}),
}
}
fn render(
config: &PageConfig,
ctx: &RenderCtx,
draw: impl FnOnce() -> Result<CspNonce, NonceError>,
root: Root<'_>,
) -> Result<PageResponse, RenderError> {
let route = || config.route_pattern.to_string();
let static_route = config
.manifest
.route(config.route_pattern)
.is_some_and(crate::RouteAssets::is_static_ssr);
if static_route {
let message = if config.config_json.is_some() {
Some("static SSR cannot execute config_json; publish a JavaScript entry for client configuration")
} else if !ctx.module_preloads(config.route_pattern).is_empty()
|| config
.manifest
.route(config.route_pattern)
.is_some_and(|assets| !assets.modulepreload.is_empty())
{
Some("static SSR cannot load module preloads; rebuild the route and its request preload table together")
} else if matches!(root, Root::Walk { module: Some(module), .. } if !module.islands.is_empty())
{
Some("static SSR contains declared islands but has no JavaScript entry")
} else {
None
};
if let Some(message) = message {
return Err(RenderError::StaticRoute {
route: route(),
message: message.into(),
});
}
}
let nonce = draw().map_err(|cause| RenderError::Nonce {
route: route(),
cause,
})?;
let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
route: route(),
cause,
})?;
let output_limit = match root {
Root::Walk { limits, .. } => limits.max_output_bytes,
Root::Shell => ctx.limits(WalkLimits::default()).max_output_bytes,
};
let (ssr_body, island_state, counts) = match root {
Root::Shell => (None, None, RenderCounts::default()),
Root::Walk {
module,
slots,
limits,
} => {
let (Some(module), Some(slots)) = (module, slots) else {
return Err(RenderError::MissingModule { route: route() });
};
let started = Instant::now();
let mode = if static_route {
forma_ir::RenderMode::Static
} else {
forma_ir::RenderMode::Adoptable
};
let walked = forma_ir::walk_to_html_counted_with_mode(module, slots, limits, mode);
let walk_time = started.elapsed();
match walked {
Ok(out) => (
Some(out.html),
(!static_route).then_some(out.island_state),
RenderCounts::from_walk(out.counts, walk_time),
),
Err(failure) => {
let route = route();
let component_path = failure.component_path;
let counts = RenderCounts::from_walk(failure.counts, walk_time);
let cause = failure.error;
return Err(match cause {
IrError::InvalidIslandState(_)
| IrError::IslandStateLimitExceeded { .. } => RenderError::IslandState {
route,
component_path,
counts,
cause,
},
IrError::OutputLimitExceeded { .. }
| IrError::ListBudgetExceeded { .. }
| IrError::ListDepthExceeded { .. }
| IrError::RecursionLimitExceeded => RenderError::Budget {
route,
component_path,
counts,
cause,
},
IrError::Panicked { message } => RenderError::Fault {
route,
component_path,
counts,
message,
},
cause => RenderError::Walk {
route,
component_path,
counts,
cause,
},
});
}
}
}
};
let html = crate::template::write_document(
config,
&nonce,
ssr_body.as_deref(),
island_state.as_ref(),
ctx,
output_limit,
counts,
)?;
if let Some(reason) = policy.relaxed() {
csp::log_relaxation(config.route_pattern, reason);
}
Ok(PageResponse::with_policy(html, nonce, policy, counts))
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"a panic with no message".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::AssetManifest;
use std::collections::HashMap;
fn manifest() -> AssetManifest {
AssetManifest {
version: 2,
writer: "3.0.0".into(),
app: "ledger".into(),
aliases: HashMap::new(),
build: "test".to_string(),
assets: HashMap::new(),
routes: HashMap::new(),
}
}
#[allow(deprecated)]
fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
PageConfig {
title: "Seam",
route_pattern: "/seam",
manifest,
config_json: None,
body_class: None,
personality_css: None,
body_prefix: None,
}
}
#[test]
fn a_keyed_row_refusal_is_render_503_and_other_walk_refusals_stay_render_102() {
use forma_ir::{ListKeyReason, ListKeyViolation};
let walk = |cause: IrError| RenderError::Walk {
route: "/seam".into(),
component_path: vec!["Rows".into()],
counts: RenderCounts::default(),
cause,
};
let keyed = walk(IrError::InvalidListKey(Box::new(ListKeyViolation::new(
30,
2,
"user.id",
ListKeyReason::Duplicate { first_row: 0 },
))));
assert_eq!(keyed.code(), "RENDER-503");
assert_eq!(keyed.component_path(), ["Rows".to_string()]);
let text = keyed.to_string();
assert!(
text.contains("RENDER-503: LIST at bytecode offset 30, row 2, key path `user.id`: the row repeats the key row 0 already rendered"),
"{text}"
);
assert_eq!(walk(IrError::InvalidOpcode(0xff)).code(), "RENDER-102");
}
#[test]
fn static_island_mismatch_refuses_before_nonce_or_walk() {
use forma_ir::parser::test_helpers::{
build_declared_fixture, encode_close_tag, encode_open_tag,
};
let mut manifest = manifest();
manifest.routes.insert(
"/seam".into(),
crate::RouteAssets {
mode: crate::RouteMode::Ssr,
js: vec![],
css: vec![],
fonts: vec![],
modulepreload: vec![],
ir: Some("home.ir".into()),
total_size_br: 0,
budget_warn_threshold: 0,
},
);
let opcodes = [
vec![11, 0, 0],
encode_open_tag(0, &[]),
encode_close_tag(0),
vec![12, 0, 0],
]
.concat();
let module = forma_ir::IrModule::parse(&build_declared_fixture(
&["div", "Panel"],
&[],
&opcodes,
&[(0, 1, 0, 1, 0, &[])],
))
.unwrap();
let error = render_guarded(
&shell_config(&manifest),
&RenderCtx::new(),
|| panic!("static refusal must precede nonce generation"),
Root::Walk {
module: Some(&module),
slots: None,
limits: WalkLimits::default(),
},
)
.unwrap_err();
assert_eq!(error.code(), "RENDER-105");
assert_eq!(error.route(), "/seam");
assert!(error.to_string().contains("declared islands"));
assert_eq!(error.counts(), None);
assert!(error.component_path().is_empty());
}
#[test]
fn render_202_a_failed_nonce_draw() {
let manifest = manifest();
for root in [
Root::Walk {
module: None,
slots: None,
limits: WalkLimits::default(),
},
Root::Shell,
] {
let error = render_guarded(
&shell_config(&manifest),
&RenderCtx::new(),
|| Err(NonceError),
root,
)
.unwrap_err();
assert_eq!(error.code(), "RENDER-202");
assert_eq!(error.route(), "/seam");
assert!(error.component_path().is_empty());
assert_eq!(error.counts(), None);
}
let page = render_guarded(
&shell_config(&manifest),
&RenderCtx::new(),
CspNonce::try_generate,
Root::Shell,
);
assert!(page.is_ok());
}
#[test]
fn a_panic_outside_the_walk_is_still_a_fault() {
let manifest = manifest();
let error = render_guarded(
&shell_config(&manifest),
&RenderCtx::new(),
|| -> Result<CspNonce, NonceError> { panic!("draw blew up") },
Root::Shell,
)
.unwrap_err();
assert_eq!(error.code(), "RENDER-399");
assert_eq!(error.route(), "/seam");
assert!(error.component_path().is_empty());
match error {
RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
other => panic!("expected Fault, got {other:?}"),
}
}
#[test]
fn an_invalid_extension_fails_before_the_walk() {
let manifest = manifest();
let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
let error = render_bound(
&shell_config(&manifest),
&ctx,
None,
None,
WalkLimits::default(),
)
.unwrap_err();
assert_eq!(error.code(), "RENDER-201");
}
#[test]
fn display_names_code_route_component_and_counts() {
let error = RenderError::Budget {
route: "/t/:tenant".to_string(),
component_path: vec!["Shell".to_string(), "Branding".to_string()],
counts: RenderCounts {
nodes: 102,
bytes: 2048,
list_iterations: 50,
..RenderCounts::default()
},
cause: IrError::ListBudgetExceeded { max: 50 },
};
let text = error.to_string();
assert!(
text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
"{text}"
);
assert!(
text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
"{text}"
);
}
}