Skip to main content

forma_server/
render.rs

1//! Checked document rendering and per-request metadata.
2//! Server rendering is entered only through a finalized bound page handle.
3
4use 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/// What a render takes beyond [`PageConfig`]: the policy's typed extensions,
14/// the walk's budgets, and the typed homes for what applications used to put
15/// in `body_prefix`: the route's `modulepreload` links from the
16/// build's manifest, and head links. Never a nonce: the render draws one per
17/// response. Data goes through [`PageConfig::config_json`].
18///
19/// ```
20/// use forma_server::{HeadLink, PolicyExtensions, RenderCtx, WalkLimits};
21///
22/// let ctx = RenderCtx::new()
23///     .with_policy(PolicyExtensions::new().img_src("https:"))
24///     .with_limits(WalkLimits { max_list_iterations: 100_000, ..WalkLimits::default() })
25///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?);
26/// # let _ = ctx;
27/// # Ok::<(), forma_server::HeadLinkError>(())
28/// ```
29#[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    /// The strict policy with limits inherited from the bound page.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Override the bound page's document title for this request; escaped as text.
49    #[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    /// Set this request's body class; escaped as an attribute.
56    #[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    /// Request data assigned to `window.__FORMA_CONFIG__` by a nonced script.
63    /// Static SSR routes refuse this executable configuration with `RENDER-105`.
64    #[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    /// Request branding CSS. The document writer neutralizes style end tags.
71    #[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    /// Explicit trusted markup before the page root; plain strings do not satisfy this API.
78    #[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    /// Extend this page's policy (see [`PolicyExtensions`]).
102    #[must_use]
103    pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
104        self.policy = policy;
105        self
106    }
107
108    /// Override both walk budgets for this request only. Without this call,
109    /// the handle's bind-time limits apply. Use its `limits()` value with struct
110    /// update syntax to change just one budget.
111    #[must_use]
112    pub fn with_limits(mut self, limits: WalkLimits) -> Self {
113        self.limits = Some(limits);
114        self
115    }
116
117    /// Use the bound page's limits unless this request explicitly overrides them.
118    pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
119        self.limits.unwrap_or(inherited)
120    }
121
122    /// Write the route's `modulepreload` links from the build's manifest,
123    /// each with the page's nonce, after the route's own modules. The
124    /// typed home for a preload workaround in `body_prefix`, such as
125    /// Ledger's. The table covers every route; the render takes
126    /// `config.route_pattern`'s list. Like every typed home, each call site
127    /// belongs on the application's raw-entry allowlist (`forma-raw-lint`).
128    ///
129    /// ```no_run
130    /// use forma_server::{assets, ModulePreloads, RenderCtx};
131    ///
132    /// // At start, beside the manifest; refuse to start on its error.
133    /// fn start<Assets: rust_embed::Embed>() -> Result<ModulePreloads, String> {
134    ///     assets::try_load_module_preloads::<Assets>()
135    /// }
136    ///
137    /// // Per request.
138    /// fn ctx(preloads: &ModulePreloads) -> RenderCtx {
139    ///     RenderCtx::new().with_module_preloads(preloads)
140    /// }
141    /// ```
142    ///
143    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
144    #[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    /// Add a `<link>` to the page's head (see [`HeadLink`]), after any added
151    /// before it. The typed home for links spliced in before `</head>`; each
152    /// call site belongs on the application's raw-entry allowlist
153    /// (`forma-raw-lint`).
154    ///
155    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
156    #[must_use]
157    pub fn with_head_link(mut self, link: HeadLink) -> Self {
158        self.head_links.push(link);
159        self
160    }
161
162    /// The route's manifest preloads, empty without a table.
163    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    /// The head links, in the order added.
170    pub(crate) fn head_links(&self) -> &[HeadLink] {
171        &self.head_links
172    }
173}
174
175/// Why a page was not rendered. Nothing reaches the browser unless the
176/// handler sends it: answer with the application's own error page.
177///
178/// Every error has a stable [`code`](RenderError::code), the
179/// [`route`](RenderError::route) it was rendering, and the
180/// [`component_path`](RenderError::component_path) where it stopped (empty
181/// when the failure is not inside a component). The codes, by family:
182///
183/// | Code | Variant | Cause |
184/// |---|---|---|
185/// | RENDER-101 | [`MissingModule`](RenderError::MissingModule) | The config has no module or no slots to render |
186/// | RENDER-104 | [`Manifest`](RenderError::Manifest) | Invalid manifest or referenced integrity |
187/// | RENDER-105 | [`StaticRoute`](RenderError::StaticRoute) | A static SSR route requires JavaScript for islands, configuration or preloads |
188/// | RENDER-103 | [`RouteMode`](RenderError::RouteMode) | A shell route is absent or not Browser |
189/// | RENDER-406 | [`Unfinalized`](RenderError::Unfinalized) | Required startup finalization is incomplete |
190/// | RENDER-102 | [`Walk`](RenderError::Walk) | The walk refused the module or its data |
191/// | RENDER-503 | [`Walk`](RenderError::Walk) | A keyed list row has no usable identity, repeats another row's key, or has no single root to carry it (FORMA-181) |
192/// | RENDER-201 | [`Policy`](RenderError::Policy) | A policy extension is not a source a page may add |
193/// | RENDER-202 | [`Nonce`](RenderError::Nonce) | The system random source failed to draw a nonce |
194/// | RENDER-301 | [`Budget`](RenderError::Budget) | The complete escaped response passed `max_output_bytes` |
195/// | RENDER-302 | [`Budget`](RenderError::Budget) | The walk's list rows passed `max_list_iterations` |
196/// | RENDER-303 | [`Budget`](RenderError::Budget) | Lists or control flow nested past the walker's depth |
197/// | RENDER-501 | [`IslandState`](RenderError::IslandState) | Declared island input mapping or value is invalid |
198/// | RENDER-502 | [`IslandState`](RenderError::IslandState) | Escaped island state exceeds one MiB |
199/// | RENDER-399 | [`Fault`](RenderError::Fault) | The render panicked; a defect, not an input |
200///
201/// A code, once published, keeps its meaning; new causes get new codes.
202#[derive(Debug, Clone, PartialEq, Eq)]
203#[non_exhaustive]
204pub enum RenderError {
205    /// RENDER-105: a static SSR route cannot execute or preload JavaScript.
206    #[non_exhaustive]
207    StaticRoute {
208        /// The requested route.
209        route: String,
210        /// The incompatible island, configuration or preload contract.
211        message: String,
212    },
213    /// RENDER-104: manifest or referenced resource integrity is invalid.
214    #[non_exhaustive]
215    Manifest {
216        /// The requested route.
217        route: String,
218        /// Manifest validation failure or the resource whose integrity is unavailable.
219        message: String,
220    },
221    /// RENDER-103: a browser shell requires an explicitly declared browser route.
222    #[non_exhaustive]
223    RouteMode {
224        /// The requested route.
225        route: String,
226    },
227    /// RENDER-406: every SSR handler must bind and the registry must finalize first.
228    #[non_exhaustive]
229    Unfinalized {
230        /// The bound route.
231        route: String,
232    },
233    /// RENDER-401..404: a handler value violates its declared contract.
234    #[non_exhaustive]
235    Props {
236        /// Checked value error, including its declared property path and sources.
237        cause: forma_ir::props::PropsError,
238    },
239    /// RENDER-405: a development reload no longer matches the bound handler.
240    #[non_exhaustive]
241    Binding {
242        /// Bound route.
243        route: String,
244        /// Binding refusals for the changed module. No partial HTML is returned.
245        errors: Vec<forma_ir::props::PropsError>,
246    },
247    /// RENDER-101: Server rendering, and the config
248    /// carries no module or no slots for it. A browser-rendered route is
249    /// served by [`render_browser_shell`], by declaration.
250    #[non_exhaustive]
251    MissingModule {
252        /// The route being rendered.
253        route: String,
254    },
255    /// RENDER-102: the walk refused the module or the data it was given.
256    #[non_exhaustive]
257    Walk {
258        /// The route being rendered.
259        route: String,
260        /// The islands open where the walk stopped, outermost first.
261        component_path: Vec<String>,
262        /// How far the walk got.
263        counts: RenderCounts,
264        /// The walker's error.
265        cause: IrError,
266    },
267    /// RENDER-501 or RENDER-502: no partial state or page is returned.
268    #[non_exhaustive]
269    IslandState {
270        /// Route whose island state was refused.
271        route: String,
272        /// Open island registration names, outermost first.
273        component_path: Vec<String>,
274        /// Body walk work completed before refusal.
275        counts: RenderCounts,
276        /// The invalid field path or fixed state-byte ceiling.
277        cause: IrError,
278    },
279    /// RENDER-201: a policy extension was refused; there is no page to send
280    /// without its policy.
281    #[non_exhaustive]
282    Policy {
283        /// The route being rendered.
284        route: String,
285        /// The refused extension.
286        cause: PolicyError,
287    },
288    /// RENDER-202: no nonce could be drawn, so no policy could bless the page.
289    #[non_exhaustive]
290    Nonce {
291        /// The route being rendered.
292        route: String,
293        /// The failed draw.
294        cause: NonceError,
295    },
296    /// RENDER-301, RENDER-302, RENDER-303: the complete response exceeded
297    /// its output-byte limit, or the walk reached a list/depth budget.
298    #[non_exhaustive]
299    Budget {
300        /// The route being rendered.
301        route: String,
302        /// The islands open where the walk stopped, outermost first.
303        component_path: Vec<String>,
304        /// Body-only walk work, including when complete response assembly refuses.
305        counts: RenderCounts,
306        /// The output, list, or depth budget that refused the response.
307        cause: IrError,
308    },
309    /// RENDER-399: the render panicked and the panic was caught. The
310    /// process and every other request carry on, while the application
311    /// unwinds on panic; under `panic = "abort"` the process ends.
312    #[non_exhaustive]
313    Fault {
314        /// The route being rendered.
315        route: String,
316        /// The islands open where the walk panicked, when it was in the walk.
317        component_path: Vec<String>,
318        /// How far the walk got, when it was in the walk.
319        counts: RenderCounts,
320        /// The panic's message.
321        message: String,
322    },
323}
324
325impl RenderError {
326    /// The stable code, such as `RENDER-301`.
327    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                // A published code keeps its meaning; the keyed-row refusal
338                // is a new cause with its own code (FORMA-181, DR-8).
339                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    /// The route pattern the render was for.
359    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    /// The declared property path of a checked-value refusal.
378    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    /// The component names of the islands open where the render stopped,
390    /// outermost first; empty when it stopped outside every island or before
391    /// the walk. A component the compiler inlined into its parent has no name
392    /// until FMIR v3's source positions.
393    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    /// How far the walk got, for an error raised during it.
404    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
480/// The sole private SSR entry: its module and slots come from a verified handle.
481pub(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
500/// Serve a route the build declares browser-rendered (`ssr: false`): the
501/// nonced bundle and the same headers as any page, around an empty root.
502///
503/// By declaration, never in place of a failure: it does not walk. Unknown and
504/// SSR routes are refused before document work. Policy, nonce and fault errors
505/// retain the same response boundary as a bound SSR page.
506///
507/// Verified by: crates/forma-server/tests/internal_renderer/checked_render.rs > "render_browser_shell_serves_the_bundle_around_an_empty_root_with_the_page_headers"
508pub 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/// What goes in the page's root.
532#[derive(Debug, Clone, Copy)]
533enum Root<'a> {
534    /// The walk's HTML.
535    Walk {
536        module: Option<&'a forma_ir::IrModule>,
537        slots: Option<&'a forma_ir::SlotData>,
538        limits: WalkLimits,
539    },
540    /// Nothing: the browser renders.
541    Shell,
542}
543
544/// The render, with the nonce source a parameter so a test can make it fail,
545/// inside the outer `catch_unwind`.
546///
547/// The walk catches its own panic, with the component path
548/// (`forma_ir::walk_to_html_counted`); this guard covers the rest of the
549/// render, which carries no `unwrap`, `expect` or `panic!` (the crate denies
550/// them) and so has no known panic to catch.
551fn 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
560/// Catch request-side unwinds, including user-defined props conversion.
561pub(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    let static_route = config
584        .manifest
585        .route(config.route_pattern)
586        .is_some_and(crate::RouteAssets::is_static_ssr);
587    if static_route {
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    // The policy before the walk: an invalid extension costs no walk.
615    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 mode = if static_route {
636                forma_ir::RenderMode::Static
637            } else {
638                forma_ir::RenderMode::Adoptable
639            };
640            let walked = forma_ir::walk_to_html_counted_with_mode(module, slots, limits, mode);
641            let walk_time = started.elapsed();
642            match walked {
643                Ok(out) => (
644                    Some(out.html),
645                    (!static_route).then_some(out.island_state),
646                    RenderCounts::from_walk(out.counts, walk_time),
647                ),
648                Err(failure) => {
649                    let route = route();
650                    let component_path = failure.component_path;
651                    let counts = RenderCounts::from_walk(failure.counts, walk_time);
652                    let cause = failure.error;
653                    return Err(match cause {
654                        IrError::InvalidIslandState(_)
655                        | IrError::IslandStateLimitExceeded { .. } => RenderError::IslandState {
656                            route,
657                            component_path,
658                            counts,
659                            cause,
660                        },
661                        IrError::OutputLimitExceeded { .. }
662                        | IrError::ListBudgetExceeded { .. }
663                        | IrError::ListDepthExceeded { .. }
664                        | IrError::RecursionLimitExceeded => RenderError::Budget {
665                            route,
666                            component_path,
667                            counts,
668                            cause,
669                        },
670                        IrError::Panicked { message } => RenderError::Fault {
671                            route,
672                            component_path,
673                            counts,
674                            message,
675                        },
676                        cause => RenderError::Walk {
677                            route,
678                            component_path,
679                            counts,
680                            cause,
681                        },
682                    });
683                }
684            }
685        }
686    };
687
688    let html = crate::template::write_document(
689        config,
690        &nonce,
691        ssr_body.as_deref(),
692        island_state.as_ref(),
693        ctx,
694        output_limit,
695        counts,
696    )?;
697    if let Some(reason) = policy.relaxed() {
698        csp::log_relaxation(config.route_pattern, reason);
699    }
700    Ok(PageResponse::with_policy(html, nonce, policy, counts))
701}
702
703/// A panic payload's message: `panic!` with a literal gives a `&str`, with
704/// formatting a `String`.
705fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
706    if let Some(message) = payload.downcast_ref::<&str>() {
707        (*message).to_string()
708    } else if let Some(message) = payload.downcast_ref::<String>() {
709        message.clone()
710    } else {
711        "a panic with no message".to_string()
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::types::AssetManifest;
719    use std::collections::HashMap;
720
721    fn manifest() -> AssetManifest {
722        AssetManifest {
723            version: 2,
724            writer: "3.0.0".into(),
725            app: "ledger".into(),
726            aliases: HashMap::new(),
727            build: "test".to_string(),
728            assets: HashMap::new(),
729            routes: HashMap::new(),
730        }
731    }
732
733    #[allow(deprecated)]
734    fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
735        PageConfig {
736            title: "Seam",
737            route_pattern: "/seam",
738            manifest,
739            config_json: None,
740            body_class: None,
741            personality_css: None,
742            body_prefix: None,
743        }
744    }
745
746    /// FORMA-181: a keyed-row refusal is a new cause of a refused walk with its own code;
747    /// every other walk refusal keeps RENDER-102.
748    #[test]
749    fn a_keyed_row_refusal_is_render_503_and_other_walk_refusals_stay_render_102() {
750        use forma_ir::{ListKeyReason, ListKeyViolation};
751        let walk = |cause: IrError| RenderError::Walk {
752            route: "/seam".into(),
753            component_path: vec!["Rows".into()],
754            counts: RenderCounts::default(),
755            cause,
756        };
757        let keyed = walk(IrError::InvalidListKey(Box::new(ListKeyViolation::new(
758            30,
759            2,
760            "user.id",
761            ListKeyReason::Duplicate { first_row: 0 },
762        ))));
763        assert_eq!(keyed.code(), "RENDER-503");
764        assert_eq!(keyed.component_path(), ["Rows".to_string()]);
765        let text = keyed.to_string();
766        assert!(
767            text.contains("RENDER-503: LIST at bytecode offset 30, row 2, key path `user.id`: the row repeats the key row 0 already rendered"),
768            "{text}"
769        );
770        assert_eq!(walk(IrError::InvalidOpcode(0xff)).code(), "RENDER-102");
771    }
772
773    #[test]
774    fn static_island_mismatch_refuses_before_nonce_or_walk() {
775        use forma_ir::parser::test_helpers::{
776            build_declared_fixture, encode_close_tag, encode_open_tag,
777        };
778        let mut manifest = manifest();
779        manifest.routes.insert(
780            "/seam".into(),
781            crate::RouteAssets {
782                mode: crate::RouteMode::Ssr,
783                js: vec![],
784                css: vec![],
785                fonts: vec![],
786                modulepreload: vec![],
787                ir: Some("home.ir".into()),
788                total_size_br: 0,
789                budget_warn_threshold: 0,
790            },
791        );
792        let opcodes = [
793            vec![11, 0, 0],
794            encode_open_tag(0, &[]),
795            encode_close_tag(0),
796            vec![12, 0, 0],
797        ]
798        .concat();
799        let module = forma_ir::IrModule::parse(&build_declared_fixture(
800            &["div", "Panel"],
801            &[],
802            &opcodes,
803            &[(0, 1, 0, 1, 0, &[])],
804        ))
805        .unwrap();
806        let error = render_guarded(
807            &shell_config(&manifest),
808            &RenderCtx::new(),
809            || panic!("static refusal must precede nonce generation"),
810            Root::Walk {
811                module: Some(&module),
812                slots: None,
813                limits: WalkLimits::default(),
814            },
815        )
816        .unwrap_err();
817        assert_eq!(error.code(), "RENDER-105");
818        assert_eq!(error.route(), "/seam");
819        assert!(error.to_string().contains("declared islands"));
820        assert_eq!(error.counts(), None);
821        assert!(error.component_path().is_empty());
822    }
823
824    #[test]
825    fn render_202_a_failed_nonce_draw() {
826        // The OS random source cannot be made to fail from a test, so the
827        // render takes its draw as a parameter and this test hands it a
828        // failing one. `try_render` passes `CspNonce::try_generate`.
829        let manifest = manifest();
830        for root in [
831            Root::Walk {
832                module: None,
833                slots: None,
834                limits: WalkLimits::default(),
835            },
836            Root::Shell,
837        ] {
838            let error = render_guarded(
839                &shell_config(&manifest),
840                &RenderCtx::new(),
841                || Err(NonceError),
842                root,
843            )
844            .unwrap_err();
845            assert_eq!(error.code(), "RENDER-202");
846            assert_eq!(error.route(), "/seam");
847            assert!(error.component_path().is_empty());
848            assert_eq!(error.counts(), None);
849        }
850        // The same seam with a real draw renders: the failure is the draw's.
851        let page = render_guarded(
852            &shell_config(&manifest),
853            &RenderCtx::new(),
854            CspNonce::try_generate,
855            Root::Shell,
856        );
857        assert!(page.is_ok());
858    }
859
860    #[test]
861    fn a_panic_outside_the_walk_is_still_a_fault() {
862        // The outer guard: a panic before the walk (here, in the draw) comes
863        // back as RENDER-399 too, with no component path.
864        let manifest = manifest();
865        let error = render_guarded(
866            &shell_config(&manifest),
867            &RenderCtx::new(),
868            || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
869            Root::Shell,
870        )
871        .unwrap_err();
872        assert_eq!(error.code(), "RENDER-399");
873        assert_eq!(error.route(), "/seam");
874        assert!(error.component_path().is_empty());
875        match error {
876            RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
877            other => panic!("expected Fault, got {other:?}"),
878        }
879    }
880
881    #[test]
882    fn an_invalid_extension_fails_before_the_walk() {
883        // No module at all: if the walk ran first this would be RENDER-101.
884        let manifest = manifest();
885        let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
886        let error = render_bound(
887            &shell_config(&manifest),
888            &ctx,
889            None,
890            None,
891            WalkLimits::default(),
892        )
893        .unwrap_err();
894        assert_eq!(error.code(), "RENDER-201");
895    }
896
897    #[test]
898    fn display_names_code_route_component_and_counts() {
899        let error = RenderError::Budget {
900            route: "/t/:tenant".to_string(),
901            component_path: vec!["Shell".to_string(), "Branding".to_string()],
902            counts: RenderCounts {
903                nodes: 102,
904                bytes: 2048,
905                list_iterations: 50,
906                ..RenderCounts::default()
907            },
908            cause: IrError::ListBudgetExceeded { max: 50 },
909        };
910        let text = error.to_string();
911        assert!(
912            text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
913            "{text}"
914        );
915        assert!(
916            text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
917            "{text}"
918        );
919    }
920}