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]
65 pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
66 self.config_json = Some(value);
67 self
68 }
69
70 #[must_use]
72 pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
73 self.personality_css = Some(css.into());
74 self
75 }
76
77 #[must_use]
79 pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
80 self.body_prefix = Some(html);
81 self
82 }
83
84 pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
85 self.config_json.as_ref()
86 }
87 pub(crate) fn personality_css(&self) -> Option<&str> {
88 self.personality_css.as_deref()
89 }
90 pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
91 self.body_prefix.as_ref()
92 }
93
94 pub(crate) fn title(&self) -> Option<&str> {
95 self.title.as_deref()
96 }
97 pub(crate) fn body_class(&self) -> Option<&str> {
98 self.body_class.as_deref()
99 }
100
101 #[must_use]
103 pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
104 self.policy = policy;
105 self
106 }
107
108 #[must_use]
112 pub fn with_limits(mut self, limits: WalkLimits) -> Self {
113 self.limits = Some(limits);
114 self
115 }
116
117 pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
119 self.limits.unwrap_or(inherited)
120 }
121
122 #[must_use]
145 pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
146 self.module_preloads = Some(preloads.clone());
147 self
148 }
149
150 #[must_use]
157 pub fn with_head_link(mut self, link: HeadLink) -> Self {
158 self.head_links.push(link);
159 self
160 }
161
162 pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
164 self.module_preloads
165 .as_ref()
166 .map_or(&[], |table| table.route(route_pattern))
167 }
168
169 pub(crate) fn head_links(&self) -> &[HeadLink] {
171 &self.head_links
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
203#[non_exhaustive]
204pub enum RenderError {
205 #[non_exhaustive]
207 StaticRoute {
208 route: String,
210 message: String,
212 },
213 #[non_exhaustive]
215 Manifest {
216 route: String,
218 message: String,
220 },
221 #[non_exhaustive]
223 RouteMode {
224 route: String,
226 },
227 #[non_exhaustive]
229 Unfinalized {
230 route: String,
232 },
233 #[non_exhaustive]
235 Props {
236 cause: forma_ir::props::PropsError,
238 },
239 #[non_exhaustive]
241 Binding {
242 route: String,
244 errors: Vec<forma_ir::props::PropsError>,
246 },
247 #[non_exhaustive]
251 MissingModule {
252 route: String,
254 },
255 #[non_exhaustive]
257 Walk {
258 route: String,
260 component_path: Vec<String>,
262 counts: RenderCounts,
264 cause: IrError,
266 },
267 #[non_exhaustive]
269 IslandState {
270 route: String,
272 component_path: Vec<String>,
274 counts: RenderCounts,
276 cause: IrError,
278 },
279 #[non_exhaustive]
282 Policy {
283 route: String,
285 cause: PolicyError,
287 },
288 #[non_exhaustive]
290 Nonce {
291 route: String,
293 cause: NonceError,
295 },
296 #[non_exhaustive]
299 Budget {
300 route: String,
302 component_path: Vec<String>,
304 counts: RenderCounts,
306 cause: IrError,
308 },
309 #[non_exhaustive]
313 Fault {
314 route: String,
316 component_path: Vec<String>,
318 counts: RenderCounts,
320 message: String,
322 },
323}
324
325impl RenderError {
326 pub fn code(&self) -> &'static str {
328 match self {
329 RenderError::StaticRoute { .. } => "RENDER-105",
330 RenderError::Manifest { .. } => "RENDER-104",
331 RenderError::RouteMode { .. } => "RENDER-103",
332 RenderError::Unfinalized { .. } => "RENDER-406",
333 RenderError::Props { cause } => cause.code(),
334 RenderError::Binding { .. } => "RENDER-405",
335 RenderError::MissingModule { .. } => "RENDER-101",
336 RenderError::Walk { cause, .. } => match cause {
337 IrError::InvalidListKey(_) => "RENDER-503",
340 _ => "RENDER-102",
341 },
342 RenderError::IslandState { cause, .. } => match cause {
343 IrError::IslandStateLimitExceeded { .. } => "RENDER-502",
344 IrError::InvalidIslandState(_) => "RENDER-501",
345 _ => "RENDER-501",
346 },
347 RenderError::Policy { .. } => "RENDER-201",
348 RenderError::Nonce { .. } => "RENDER-202",
349 RenderError::Budget { cause, .. } => match cause {
350 IrError::OutputLimitExceeded { .. } => "RENDER-301",
351 IrError::ListBudgetExceeded { .. } => "RENDER-302",
352 _ => "RENDER-303",
353 },
354 RenderError::Fault { .. } => "RENDER-399",
355 }
356 }
357
358 pub fn route(&self) -> &str {
360 match self {
361 RenderError::StaticRoute { route, .. }
362 | RenderError::Manifest { route, .. }
363 | RenderError::RouteMode { route }
364 | RenderError::Unfinalized { route } => route,
365 RenderError::Props { cause } => cause.route(),
366 RenderError::Binding { route, .. } => route,
367 RenderError::MissingModule { route }
368 | RenderError::Walk { route, .. }
369 | RenderError::IslandState { route, .. }
370 | RenderError::Policy { route, .. }
371 | RenderError::Nonce { route, .. }
372 | RenderError::Budget { route, .. }
373 | RenderError::Fault { route, .. } => route,
374 }
375 }
376
377 pub fn property_path(&self) -> Option<&str> {
379 match self {
380 Self::Props { cause } => Some(cause.path()),
381 Self::IslandState {
382 cause: IrError::InvalidIslandState(problem),
383 ..
384 } => Some(&problem.path),
385 _ => None,
386 }
387 }
388
389 pub fn component_path(&self) -> &[String] {
394 match self {
395 RenderError::Walk { component_path, .. }
396 | RenderError::IslandState { component_path, .. }
397 | RenderError::Budget { component_path, .. }
398 | RenderError::Fault { component_path, .. } => component_path,
399 _ => &[],
400 }
401 }
402
403 pub fn counts(&self) -> Option<RenderCounts> {
405 match self {
406 RenderError::Walk { counts, .. }
407 | RenderError::IslandState { counts, .. }
408 | RenderError::Budget { counts, .. }
409 | RenderError::Fault { counts, .. } => Some(*counts),
410 _ => None,
411 }
412 }
413}
414
415impl fmt::Display for RenderError {
416 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417 write!(f, "{} {}", self.code(), self.route())?;
418 let path = self.component_path();
419 if !path.is_empty() {
420 write!(f, " in {}", path.join(" > "))?;
421 }
422 match self {
423 RenderError::StaticRoute { message, .. } | RenderError::Manifest { message, .. } => {
424 write!(f, ": {message}")
425 }
426 RenderError::RouteMode { .. } => {
427 write!(f, ": route is not explicitly browser-rendered")
428 }
429 RenderError::Unfinalized { .. } => write!(
430 f,
431 ": bind every SSR route and finalize Pages before serving"
432 ),
433 RenderError::Props { cause } => write!(f, ": {cause}"),
434 RenderError::Binding { errors, .. } => {
435 for error in errors {
436 write!(f, "; {error}")?;
437 }
438 Ok(())
439 }
440 RenderError::MissingModule { .. } => {
441 write!(f, ": no module or slots to render on the server")
442 }
443 RenderError::Walk { cause, .. }
444 | RenderError::IslandState { cause, .. }
445 | RenderError::Budget { cause, .. } => {
446 write!(f, ": {cause}")
447 }
448 RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
449 RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
450 RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
451 }?;
452 if let Some(c) = self.counts() {
453 write!(
454 f,
455 " (after {} nodes, {} bytes, {} list rows)",
456 c.nodes, c.bytes, c.list_iterations
457 )?;
458 }
459 Ok(())
460 }
461}
462
463impl std::error::Error for RenderError {
464 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
465 match self {
466 RenderError::Walk { cause, .. }
467 | RenderError::IslandState { cause, .. }
468 | RenderError::Budget { cause, .. } => Some(cause),
469 RenderError::Props { cause } => Some(cause),
470 RenderError::Binding { errors, .. } => {
471 errors.first().map(|error| error as &dyn std::error::Error)
472 }
473 RenderError::Policy { cause, .. } => Some(cause),
474 RenderError::Nonce { cause, .. } => Some(cause),
475 _ => None,
476 }
477 }
478}
479
480pub(crate) fn render_bound(
482 config: &PageConfig,
483 ctx: &RenderCtx,
484 module: Option<&forma_ir::IrModule>,
485 slots: Option<&forma_ir::SlotData>,
486 limits: WalkLimits,
487) -> Result<PageResponse, RenderError> {
488 render_guarded(
489 config,
490 ctx,
491 CspNonce::try_generate,
492 Root::Walk {
493 module,
494 slots,
495 limits,
496 },
497 )
498}
499
500pub fn render_browser_shell(
509 config: &PageConfig,
510 ctx: &RenderCtx,
511) -> Result<PageResponse, RenderError> {
512 if !config
513 .manifest
514 .route(config.route_pattern)
515 .is_some_and(|route| route.mode == crate::RouteMode::Browser)
516 {
517 return Err(RenderError::RouteMode {
518 route: config.route_pattern.into(),
519 });
520 }
521 config
522 .manifest
523 .validate()
524 .map_err(|message| RenderError::Manifest {
525 route: config.route_pattern.into(),
526 message,
527 })?;
528 render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
529}
530
531#[derive(Debug, Clone, Copy)]
533enum Root<'a> {
534 Walk {
536 module: Option<&'a forma_ir::IrModule>,
537 slots: Option<&'a forma_ir::SlotData>,
538 limits: WalkLimits,
539 },
540 Shell,
542}
543
544fn render_guarded(
552 config: &PageConfig,
553 ctx: &RenderCtx,
554 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
555 root: Root<'_>,
556) -> Result<PageResponse, RenderError> {
557 guard(config.route_pattern, || render(config, ctx, draw, root))
558}
559
560pub(crate) fn guard(
562 route: &str,
563 render: impl FnOnce() -> Result<PageResponse, RenderError>,
564) -> Result<PageResponse, RenderError> {
565 match catch_unwind(AssertUnwindSafe(render)) {
566 Ok(result) => result,
567 Err(payload) => Err(RenderError::Fault {
568 route: route.to_string(),
569 component_path: Vec::new(),
570 counts: RenderCounts::default(),
571 message: panic_message(payload.as_ref()),
572 }),
573 }
574}
575
576fn render(
577 config: &PageConfig,
578 ctx: &RenderCtx,
579 draw: impl FnOnce() -> Result<CspNonce, NonceError>,
580 root: Root<'_>,
581) -> Result<PageResponse, RenderError> {
582 let route = || config.route_pattern.to_string();
583 if config
584 .manifest
585 .route(config.route_pattern)
586 .is_some_and(crate::RouteAssets::is_static_ssr)
587 {
588 let message = if config.config_json.is_some() {
589 Some("static SSR cannot execute config_json; publish a JavaScript entry for client configuration")
590 } else if !ctx.module_preloads(config.route_pattern).is_empty()
591 || config
592 .manifest
593 .route(config.route_pattern)
594 .is_some_and(|assets| !assets.modulepreload.is_empty())
595 {
596 Some("static SSR cannot load module preloads; rebuild the route and its request preload table together")
597 } else if matches!(root, Root::Walk { module: Some(module), .. } if !module.islands.is_empty())
598 {
599 Some("static SSR contains declared islands but has no JavaScript entry")
600 } else {
601 None
602 };
603 if let Some(message) = message {
604 return Err(RenderError::StaticRoute {
605 route: route(),
606 message: message.into(),
607 });
608 }
609 }
610 let nonce = draw().map_err(|cause| RenderError::Nonce {
611 route: route(),
612 cause,
613 })?;
614 let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
616 route: route(),
617 cause,
618 })?;
619
620 let output_limit = match root {
621 Root::Walk { limits, .. } => limits.max_output_bytes,
622 Root::Shell => ctx.limits(WalkLimits::default()).max_output_bytes,
623 };
624 let (ssr_body, island_state, counts) = match root {
625 Root::Shell => (None, None, RenderCounts::default()),
626 Root::Walk {
627 module,
628 slots,
629 limits,
630 } => {
631 let (Some(module), Some(slots)) = (module, slots) else {
632 return Err(RenderError::MissingModule { route: route() });
633 };
634 let started = Instant::now();
635 let walked = forma_ir::walk_to_html_counted(module, slots, limits);
636 let walk_time = started.elapsed();
637 match walked {
638 Ok(out) => (
639 Some(out.html),
640 Some(out.island_state),
641 RenderCounts::from_walk(out.counts, walk_time),
642 ),
643 Err(failure) => {
644 let route = route();
645 let component_path = failure.component_path;
646 let counts = RenderCounts::from_walk(failure.counts, walk_time);
647 let cause = failure.error;
648 return Err(match cause {
649 IrError::InvalidIslandState(_)
650 | IrError::IslandStateLimitExceeded { .. } => RenderError::IslandState {
651 route,
652 component_path,
653 counts,
654 cause,
655 },
656 IrError::OutputLimitExceeded { .. }
657 | IrError::ListBudgetExceeded { .. }
658 | IrError::ListDepthExceeded { .. }
659 | IrError::RecursionLimitExceeded => RenderError::Budget {
660 route,
661 component_path,
662 counts,
663 cause,
664 },
665 IrError::Panicked { message } => RenderError::Fault {
666 route,
667 component_path,
668 counts,
669 message,
670 },
671 cause => RenderError::Walk {
672 route,
673 component_path,
674 counts,
675 cause,
676 },
677 });
678 }
679 }
680 }
681 };
682
683 let html = crate::template::write_document(
684 config,
685 &nonce,
686 ssr_body.as_deref(),
687 island_state.as_ref(),
688 ctx,
689 output_limit,
690 counts,
691 )?;
692 if let Some(reason) = policy.relaxed() {
693 csp::log_relaxation(config.route_pattern, reason);
694 }
695 Ok(PageResponse::with_policy(html, nonce, policy, counts))
696}
697
698fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
701 if let Some(message) = payload.downcast_ref::<&str>() {
702 (*message).to_string()
703 } else if let Some(message) = payload.downcast_ref::<String>() {
704 message.clone()
705 } else {
706 "a panic with no message".to_string()
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::types::AssetManifest;
714 use std::collections::HashMap;
715
716 fn manifest() -> AssetManifest {
717 AssetManifest {
718 version: 2,
719 writer: "3.0.0".into(),
720 app: "ledger".into(),
721 aliases: HashMap::new(),
722 build: "test".to_string(),
723 assets: HashMap::new(),
724 routes: HashMap::new(),
725 }
726 }
727
728 #[allow(deprecated)]
729 fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
730 PageConfig {
731 title: "Seam",
732 route_pattern: "/seam",
733 manifest,
734 config_json: None,
735 body_class: None,
736 personality_css: None,
737 body_prefix: None,
738 }
739 }
740
741 #[test]
744 fn a_keyed_row_refusal_is_render_503_and_other_walk_refusals_stay_render_102() {
745 use forma_ir::{ListKeyReason, ListKeyViolation};
746 let walk = |cause: IrError| RenderError::Walk {
747 route: "/seam".into(),
748 component_path: vec!["Rows".into()],
749 counts: RenderCounts::default(),
750 cause,
751 };
752 let keyed = walk(IrError::InvalidListKey(Box::new(ListKeyViolation::new(
753 30,
754 2,
755 "user.id",
756 ListKeyReason::Duplicate { first_row: 0 },
757 ))));
758 assert_eq!(keyed.code(), "RENDER-503");
759 assert_eq!(keyed.component_path(), ["Rows".to_string()]);
760 let text = keyed.to_string();
761 assert!(
762 text.contains("RENDER-503: LIST at bytecode offset 30, row 2, key path `user.id`: the row repeats the key row 0 already rendered"),
763 "{text}"
764 );
765 assert_eq!(walk(IrError::InvalidOpcode(0xff)).code(), "RENDER-102");
766 }
767
768 #[test]
769 fn static_island_mismatch_refuses_before_nonce_or_walk() {
770 use forma_ir::parser::test_helpers::{
771 build_declared_fixture, encode_close_tag, encode_open_tag,
772 };
773 let mut manifest = manifest();
774 manifest.routes.insert(
775 "/seam".into(),
776 crate::RouteAssets {
777 mode: crate::RouteMode::Ssr,
778 js: vec![],
779 css: vec![],
780 fonts: vec![],
781 modulepreload: vec![],
782 ir: Some("home.ir".into()),
783 total_size_br: 0,
784 budget_warn_threshold: 0,
785 },
786 );
787 let opcodes = [
788 vec![11, 0, 0],
789 encode_open_tag(0, &[]),
790 encode_close_tag(0),
791 vec![12, 0, 0],
792 ]
793 .concat();
794 let module = forma_ir::IrModule::parse(&build_declared_fixture(
795 &["div", "Panel"],
796 &[],
797 &opcodes,
798 &[(0, 1, 0, 1, 0, &[])],
799 ))
800 .unwrap();
801 let error = render_guarded(
802 &shell_config(&manifest),
803 &RenderCtx::new(),
804 || panic!("static refusal must precede nonce generation"),
805 Root::Walk {
806 module: Some(&module),
807 slots: None,
808 limits: WalkLimits::default(),
809 },
810 )
811 .unwrap_err();
812 assert_eq!(error.code(), "RENDER-105");
813 assert_eq!(error.route(), "/seam");
814 assert!(error.to_string().contains("declared islands"));
815 assert_eq!(error.counts(), None);
816 assert!(error.component_path().is_empty());
817 }
818
819 #[test]
820 fn render_202_a_failed_nonce_draw() {
821 let manifest = manifest();
825 for root in [
826 Root::Walk {
827 module: None,
828 slots: None,
829 limits: WalkLimits::default(),
830 },
831 Root::Shell,
832 ] {
833 let error = render_guarded(
834 &shell_config(&manifest),
835 &RenderCtx::new(),
836 || Err(NonceError),
837 root,
838 )
839 .unwrap_err();
840 assert_eq!(error.code(), "RENDER-202");
841 assert_eq!(error.route(), "/seam");
842 assert!(error.component_path().is_empty());
843 assert_eq!(error.counts(), None);
844 }
845 let page = render_guarded(
847 &shell_config(&manifest),
848 &RenderCtx::new(),
849 CspNonce::try_generate,
850 Root::Shell,
851 );
852 assert!(page.is_ok());
853 }
854
855 #[test]
856 fn a_panic_outside_the_walk_is_still_a_fault() {
857 let manifest = manifest();
860 let error = render_guarded(
861 &shell_config(&manifest),
862 &RenderCtx::new(),
863 || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
864 Root::Shell,
865 )
866 .unwrap_err();
867 assert_eq!(error.code(), "RENDER-399");
868 assert_eq!(error.route(), "/seam");
869 assert!(error.component_path().is_empty());
870 match error {
871 RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
872 other => panic!("expected Fault, got {other:?}"),
873 }
874 }
875
876 #[test]
877 fn an_invalid_extension_fails_before_the_walk() {
878 let manifest = manifest();
880 let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
881 let error = render_bound(
882 &shell_config(&manifest),
883 &ctx,
884 None,
885 None,
886 WalkLimits::default(),
887 )
888 .unwrap_err();
889 assert_eq!(error.code(), "RENDER-201");
890 }
891
892 #[test]
893 fn display_names_code_route_component_and_counts() {
894 let error = RenderError::Budget {
895 route: "/t/:tenant".to_string(),
896 component_path: vec!["Shell".to_string(), "Branding".to_string()],
897 counts: RenderCounts {
898 nodes: 102,
899 bytes: 2048,
900 list_iterations: 50,
901 ..RenderCounts::default()
902 },
903 cause: IrError::ListBudgetExceeded { max: 50 },
904 };
905 let text = error.to_string();
906 assert!(
907 text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
908 "{text}"
909 );
910 assert!(
911 text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
912 "{text}"
913 );
914 }
915}