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: WalkLimits,
module_preloads: Option<ModulePreloads>,
head_links: Vec<HeadLink>,
}
impl RenderCtx {
pub fn new() -> Self {
Self::default()
}
#[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 = limits;
self
}
#[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]
MissingModule {
route: String,
},
#[non_exhaustive]
Walk {
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::MissingModule { .. } => "RENDER-101",
RenderError::Walk { .. } => "RENDER-102",
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::MissingModule { route }
| RenderError::Walk { route, .. }
| RenderError::Policy { route, .. }
| RenderError::Nonce { route, .. }
| RenderError::Budget { route, .. }
| RenderError::Fault { route, .. } => route,
}
}
pub fn component_path(&self) -> &[String] {
match self {
RenderError::Walk { component_path, .. }
| RenderError::Budget { component_path, .. }
| RenderError::Fault { component_path, .. } => component_path,
_ => &[],
}
}
pub fn counts(&self) -> Option<RenderCounts> {
match self {
RenderError::Walk { 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::MissingModule { .. } => {
write!(f, ": no module or slots to render on the server")
}
RenderError::Walk { 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::Budget { cause, .. } => Some(cause),
RenderError::Policy { cause, .. } => Some(cause),
RenderError::Nonce { cause, .. } => Some(cause),
_ => None,
}
}
}
pub fn try_render(config: &PageConfig, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
render_guarded(config, ctx, CspNonce::try_generate, Root::Walk)
}
pub fn render_browser_shell(
config: &PageConfig,
ctx: &RenderCtx,
) -> Result<PageResponse, RenderError> {
render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
}
#[derive(Debug, Clone, Copy)]
enum Root {
Walk,
Shell,
}
fn render_guarded(
config: &PageConfig,
ctx: &RenderCtx,
draw: impl FnOnce() -> Result<CspNonce, NonceError>,
root: Root,
) -> Result<PageResponse, RenderError> {
match catch_unwind(AssertUnwindSafe(|| render(config, ctx, draw, root))) {
Ok(result) => result,
Err(payload) => Err(RenderError::Fault {
route: config.route_pattern.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 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 (ssr_body, counts) = match root {
Root::Shell => (None, RenderCounts::default()),
Root::Walk => {
let (Some(module), Some(slots)) = (config.ir_module, config.slots) else {
return Err(RenderError::MissingModule { route: route() });
};
let started = Instant::now();
let walked = forma_ir::walk_to_html_counted(module, slots, ctx.limits);
let walk_time = started.elapsed();
match walked {
Ok(out) => (
Some(out.html),
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::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(), ctx);
if let Some(reason) = policy.relaxed() {
csp::log_relaxation(Some(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, RenderMode};
use std::collections::HashMap;
#[allow(deprecated)] fn manifest() -> AssetManifest {
AssetManifest {
version: 1,
build_hash: "test".to_string(),
assets: HashMap::new(),
routes: HashMap::new(),
wasm: None,
}
}
#[allow(deprecated)]
fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
PageConfig {
title: "Seam",
route_pattern: "/seam",
manifest,
config_script: None,
config_json: None,
body_class: None,
personality_css: None,
body_prefix: None,
render_mode: RenderMode::Phase1ClientMount,
ir_module: None,
slots: None,
}
}
#[test]
fn render_202_a_failed_nonce_draw() {
let manifest = manifest();
for root in [Root::Walk, 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 = try_render(&shell_config(&manifest), &ctx).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}"
);
}
}