1use crate::csp::{self, CspNonce, NonceError, PolicyError, PolicyExtensions};
5use crate::head::{HeadLink, ModulePreloads};
6use crate::response::{PageResponse, RenderCounts};
7use crate::types::PageConfig;
8use forma_ir::{IrError, WalkLimits};
9use std::fmt;
10use std::panic::{catch_unwind, AssertUnwindSafe};
11use std::time::Instant;
12
13#[derive(Debug, Clone, Default)]
30pub struct RenderCtx {
31 policy: PolicyExtensions,
32 limits: Option<WalkLimits>,
33 module_preloads: Option<ModulePreloads>,
34 head_links: Vec<HeadLink>,
35 title: Option<String>,
36 body_class: Option<String>,
37 config_json: Option<serde_json::Value>,
38 personality_css: Option<String>,
39 body_prefix: Option<crate::RawHtml>,
40}
41
42impl RenderCtx {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
50 pub fn with_title(mut self, title: impl Into<String>) -> Self {
51 self.title = Some(title.into());
52 self
53 }
54
55 #[must_use]
57 pub fn with_body_class(mut self, class: impl Into<String>) -> Self {
58 self.body_class = Some(class.into());
59 self
60 }
61
62 #[must_use]
64 pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
65 self.config_json = Some(value);
66 self
67 }
68
69 #[must_use]
71 pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
72 self.personality_css = Some(css.into());
73 self
74 }
75
76 #[must_use]
78 pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
79 self.body_prefix = Some(html);
80 self
81 }
82
83 pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
84 self.config_json.as_ref()
85 }
86 pub(crate) fn personality_css(&self) -> Option<&str> {
87 self.personality_css.as_deref()
88 }
89 pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
90 self.body_prefix.as_ref()
91 }
92
93 pub(crate) fn title(&self) -> Option<&str> {
94 self.title.as_deref()
95 }
96 pub(crate) fn body_class(&self) -> Option<&str> {
97 self.body_class.as_deref()
98 }
99
100 #[must_use]
102 pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
103 self.policy = policy;
104 self
105 }
106
107 #[must_use]
111 pub fn with_limits(mut self, limits: WalkLimits) -> Self {
112 self.limits = Some(limits);
113 self
114 }
115
116 pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
118 self.limits.unwrap_or(inherited)
119 }
120
121 #[must_use]
144 pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
145 self.module_preloads = Some(preloads.clone());
146 self
147 }
148
149 #[must_use]
156 pub fn with_head_link(mut self, link: HeadLink) -> Self {
157 self.head_links.push(link);
158 self
159 }
160
161 pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
163 self.module_preloads
164 .as_ref()
165 .map_or(&[], |table| table.route(route_pattern))
166 }
167
168 pub(crate) fn head_links(&self) -> &[HeadLink] {
170 &self.head_links
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
198#[non_exhaustive]
199pub enum RenderError {
200 #[non_exhaustive]
202 Manifest {
203 route: String,
205 message: String,
207 },
208 #[non_exhaustive]
210 RouteMode {
211 route: String,
213 },
214 #[non_exhaustive]
216 Unfinalized {
217 route: String,
219 },
220 #[non_exhaustive]
222 Props {
223 cause: forma_ir::props::PropsError,
225 },
226 #[non_exhaustive]
228 Binding {
229 route: String,
231 errors: Vec<forma_ir::props::PropsError>,
233 },
234 #[non_exhaustive]
238 MissingModule {
239 route: String,
241 },
242 #[non_exhaustive]
244 Walk {
245 route: String,
247 component_path: Vec<String>,
249 counts: RenderCounts,
251 cause: IrError,
253 },
254 #[non_exhaustive]
257 Policy {
258 route: String,
260 cause: PolicyError,
262 },
263 #[non_exhaustive]
265 Nonce {
266 route: String,
268 cause: NonceError,
270 },
271 #[non_exhaustive]
274 Budget {
275 route: String,
277 component_path: Vec<String>,
279 counts: RenderCounts,
281 cause: IrError,
283 },
284 #[non_exhaustive]
288 Fault {
289 route: String,
291 component_path: Vec<String>,
293 counts: RenderCounts,
295 message: String,
297 },
298}
299
300impl RenderError {
301 pub fn code(&self) -> &'static str {
303 match self {
304 RenderError::Manifest { .. } => "RENDER-104",
305 RenderError::RouteMode { .. } => "RENDER-103",
306 RenderError::Unfinalized { .. } => "RENDER-406",
307 RenderError::Props { cause } => cause.code(),
308 RenderError::Binding { .. } => "RENDER-405",
309 RenderError::MissingModule { .. } => "RENDER-101",
310 RenderError::Walk { .. } => "RENDER-102",
311 RenderError::Policy { .. } => "RENDER-201",
312 RenderError::Nonce { .. } => "RENDER-202",
313 RenderError::Budget { cause, .. } => match cause {
314 IrError::OutputLimitExceeded { .. } => "RENDER-301",
315 IrError::ListBudgetExceeded { .. } => "RENDER-302",
316 _ => "RENDER-303",
317 },
318 RenderError::Fault { .. } => "RENDER-399",
319 }
320 }
321
322 pub fn route(&self) -> &str {
324 match self {
325 RenderError::Manifest { route, .. }
326 | RenderError::RouteMode { route }
327 | RenderError::Unfinalized { route } => route,
328 RenderError::Props { cause } => cause.route(),
329 RenderError::Binding { route, .. } => route,
330 RenderError::MissingModule { route }
331 | RenderError::Walk { route, .. }
332 | RenderError::Policy { route, .. }
333 | RenderError::Nonce { route, .. }
334 | RenderError::Budget { route, .. }
335 | RenderError::Fault { route, .. } => route,
336 }
337 }
338
339 pub fn property_path(&self) -> Option<&str> {
341 match self {
342 Self::Props { cause } => Some(cause.path()),
343 _ => None,
344 }
345 }
346
347 pub fn component_path(&self) -> &[String] {
352 match self {
353 RenderError::Walk { component_path, .. }
354 | RenderError::Budget { component_path, .. }
355 | RenderError::Fault { component_path, .. } => component_path,
356 _ => &[],
357 }
358 }
359
360 pub fn counts(&self) -> Option<RenderCounts> {
362 match self {
363 RenderError::Walk { counts, .. }
364 | RenderError::Budget { counts, .. }
365 | RenderError::Fault { counts, .. } => Some(*counts),
366 _ => None,
367 }
368 }
369}
370
371impl fmt::Display for RenderError {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 write!(f, "{} {}", self.code(), self.route())?;
374 let path = self.component_path();
375 if !path.is_empty() {
376 write!(f, " in {}", path.join(" > "))?;
377 }
378 match self {
379 RenderError::Manifest { message, .. } => write!(f, ": {message}"),
380 RenderError::RouteMode { .. } => {
381 write!(f, ": route is not explicitly browser-rendered")
382 }
383 RenderError::Unfinalized { .. } => write!(
384 f,
385 ": bind every SSR route and finalize Pages before serving"
386 ),
387 RenderError::Props { cause } => write!(f, ": {cause}"),
388 RenderError::Binding { errors, .. } => {
389 for error in errors {
390 write!(f, "; {error}")?;
391 }
392 Ok(())
393 }
394 RenderError::MissingModule { .. } => {
395 write!(f, ": no module or slots to render on the server")
396 }
397 RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => {
398 write!(f, ": {cause}")
399 }
400 RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
401 RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
402 RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
403 }?;
404 if let Some(c) = self.counts() {
405 write!(
406 f,
407 " (after {} nodes, {} bytes, {} list rows)",
408 c.nodes, c.bytes, c.list_iterations
409 )?;
410 }
411 Ok(())
412 }
413}
414
415impl std::error::Error for RenderError {
416 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
417 match self {
418 RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => Some(cause),
419 RenderError::Props { cause } => Some(cause),
420 RenderError::Binding { errors, .. } => {
421 errors.first().map(|error| error as &dyn std::error::Error)
422 }
423 RenderError::Policy { cause, .. } => Some(cause),
424 RenderError::Nonce { cause, .. } => Some(cause),
425 _ => None,
426 }
427 }
428}
429
430pub(crate) fn render_bound(
432 config: &PageConfig,
433 ctx: &RenderCtx,
434 module: Option<&forma_ir::IrModule>,
435 slots: Option<&forma_ir::SlotData>,
436 limits: WalkLimits,
437) -> Result<PageResponse, RenderError> {
438 render_guarded(
439 config,
440 ctx,
441 CspNonce::try_generate,
442 Root::Walk {
443 module,
444 slots,
445 limits,
446 },
447 )
448}
449
450pub fn render_browser_shell(
459 config: &PageConfig,
460 ctx: &RenderCtx,
461) -> Result<PageResponse, RenderError> {
462 if !config
463 .manifest
464 .route(config.route_pattern)
465 .is_some_and(|route| route.mode == crate::RouteMode::Browser)
466 {
467 return Err(RenderError::RouteMode {
468 route: config.route_pattern.into(),
469 });
470 }
471 config
472 .manifest
473 .validate()
474 .map_err(|message| RenderError::Manifest {
475 route: config.route_pattern.into(),
476 message,
477 })?;
478 render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
479}
480
481#[derive(Debug, Clone, Copy)]
483enum Root<'a> {
484 Walk {
486 module: Option<&'a forma_ir::IrModule>,
487 slots: Option<&'a forma_ir::SlotData>,
488 limits: WalkLimits,
489 },
490 Shell,
492}
493
494fn render_guarded(
502 config: &PageConfig,
503 ctx: &RenderCtx,
504 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
505 root: Root<'_>,
506) -> Result<PageResponse, RenderError> {
507 guard(config.route_pattern, || render(config, ctx, draw, root))
508}
509
510pub(crate) fn guard(
512 route: &str,
513 render: impl FnOnce() -> Result<PageResponse, RenderError>,
514) -> Result<PageResponse, RenderError> {
515 match catch_unwind(AssertUnwindSafe(render)) {
516 Ok(result) => result,
517 Err(payload) => Err(RenderError::Fault {
518 route: route.to_string(),
519 component_path: Vec::new(),
520 counts: RenderCounts::default(),
521 message: panic_message(payload.as_ref()),
522 }),
523 }
524}
525
526fn render(
527 config: &PageConfig,
528 ctx: &RenderCtx,
529 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
530 root: Root<'_>,
531) -> Result<PageResponse, RenderError> {
532 let route = || config.route_pattern.to_string();
533 let nonce = draw().map_err(|cause| RenderError::Nonce {
534 route: route(),
535 cause,
536 })?;
537 let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
539 route: route(),
540 cause,
541 })?;
542
543 let (ssr_body, counts) = match root {
544 Root::Shell => (None, RenderCounts::default()),
545 Root::Walk {
546 module,
547 slots,
548 limits,
549 } => {
550 let (Some(module), Some(slots)) = (module, slots) else {
551 return Err(RenderError::MissingModule { route: route() });
552 };
553 let started = Instant::now();
554 let walked = forma_ir::walk_to_html_counted(module, slots, limits);
555 let walk_time = started.elapsed();
556 match walked {
557 Ok(out) => (
558 Some(out.html),
559 RenderCounts::from_walk(out.counts, walk_time),
560 ),
561 Err(failure) => {
562 let route = route();
563 let component_path = failure.component_path;
564 let counts = RenderCounts::from_walk(failure.counts, walk_time);
565 let cause = failure.error;
566 return Err(match cause {
567 IrError::OutputLimitExceeded { .. }
568 | IrError::ListBudgetExceeded { .. }
569 | IrError::ListDepthExceeded { .. }
570 | IrError::RecursionLimitExceeded => RenderError::Budget {
571 route,
572 component_path,
573 counts,
574 cause,
575 },
576 IrError::Panicked { message } => RenderError::Fault {
577 route,
578 component_path,
579 counts,
580 message,
581 },
582 cause => RenderError::Walk {
583 route,
584 component_path,
585 counts,
586 cause,
587 },
588 });
589 }
590 }
591 }
592 };
593
594 let html = crate::template::write_document(config, &nonce, ssr_body.as_deref(), ctx)?;
595 if let Some(reason) = policy.relaxed() {
596 csp::log_relaxation(config.route_pattern, reason);
597 }
598 Ok(PageResponse::with_policy(html, nonce, policy, counts))
599}
600
601fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
604 if let Some(message) = payload.downcast_ref::<&str>() {
605 (*message).to_string()
606 } else if let Some(message) = payload.downcast_ref::<String>() {
607 message.clone()
608 } else {
609 "a panic with no message".to_string()
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::types::AssetManifest;
617 use std::collections::HashMap;
618
619 fn manifest() -> AssetManifest {
620 AssetManifest {
621 version: 2,
622 writer: "3.0.0".into(),
623 app: "ledger".into(),
624 aliases: HashMap::new(),
625 build: "test".to_string(),
626 assets: HashMap::new(),
627 routes: HashMap::new(),
628 }
629 }
630
631 #[allow(deprecated)]
632 fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
633 PageConfig {
634 title: "Seam",
635 route_pattern: "/seam",
636 manifest,
637 config_json: None,
638 body_class: None,
639 personality_css: None,
640 body_prefix: None,
641 }
642 }
643
644 #[test]
645 fn render_202_a_failed_nonce_draw() {
646 let manifest = manifest();
650 for root in [
651 Root::Walk {
652 module: None,
653 slots: None,
654 limits: WalkLimits::default(),
655 },
656 Root::Shell,
657 ] {
658 let error = render_guarded(
659 &shell_config(&manifest),
660 &RenderCtx::new(),
661 || Err(NonceError),
662 root,
663 )
664 .unwrap_err();
665 assert_eq!(error.code(), "RENDER-202");
666 assert_eq!(error.route(), "/seam");
667 assert!(error.component_path().is_empty());
668 assert_eq!(error.counts(), None);
669 }
670 let page = render_guarded(
672 &shell_config(&manifest),
673 &RenderCtx::new(),
674 CspNonce::try_generate,
675 Root::Shell,
676 );
677 assert!(page.is_ok());
678 }
679
680 #[test]
681 fn a_panic_outside_the_walk_is_still_a_fault() {
682 let manifest = manifest();
685 let error = render_guarded(
686 &shell_config(&manifest),
687 &RenderCtx::new(),
688 || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
689 Root::Shell,
690 )
691 .unwrap_err();
692 assert_eq!(error.code(), "RENDER-399");
693 assert_eq!(error.route(), "/seam");
694 assert!(error.component_path().is_empty());
695 match error {
696 RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
697 other => panic!("expected Fault, got {other:?}"),
698 }
699 }
700
701 #[test]
702 fn an_invalid_extension_fails_before_the_walk() {
703 let manifest = manifest();
705 let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
706 let error = render_bound(
707 &shell_config(&manifest),
708 &ctx,
709 None,
710 None,
711 WalkLimits::default(),
712 )
713 .unwrap_err();
714 assert_eq!(error.code(), "RENDER-201");
715 }
716
717 #[test]
718 fn display_names_code_route_component_and_counts() {
719 let error = RenderError::Budget {
720 route: "/t/:tenant".to_string(),
721 component_path: vec!["Shell".to_string(), "Branding".to_string()],
722 counts: RenderCounts {
723 nodes: 102,
724 bytes: 2048,
725 list_iterations: 50,
726 ..RenderCounts::default()
727 },
728 cause: IrError::ListBudgetExceeded { max: 50 },
729 };
730 let text = error.to_string();
731 assert!(
732 text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
733 "{text}"
734 );
735 assert!(
736 text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
737 "{text}"
738 );
739 }
740}