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    #[cfg(feature = "navigation")]
32    navigation: Option<crate::navigation::NavigationConfig>,
33    policy: PolicyExtensions,
34    limits: Option<WalkLimits>,
35    module_preloads: Option<ModulePreloads>,
36    head_links: Vec<HeadLink>,
37    title: Option<String>,
38    body_class: Option<String>,
39    config_json: Option<serde_json::Value>,
40    personality_css: Option<String>,
41    body_prefix: Option<crate::RawHtml>,
42}
43
44impl RenderCtx {
45    /// The strict policy with limits inherited from the bound page.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Opt into native owner navigation. Ordinary responses remain
51    /// complete documents; this metadata does not authorize a client request.
52    #[cfg(feature = "navigation")]
53    #[must_use]
54    pub fn with_navigation(mut self, config: crate::navigation::NavigationConfig) -> Self {
55        self.navigation = Some(config);
56        self
57    }
58
59    #[cfg(feature = "navigation")]
60    pub(crate) fn navigation(&self) -> Option<&crate::navigation::NavigationConfig> {
61        self.navigation.as_ref()
62    }
63
64    #[cfg(feature = "navigation")]
65    pub(crate) fn policy_extensions(&self) -> &PolicyExtensions {
66        &self.policy
67    }
68
69    /// Override the bound page's document title for this request; escaped as text.
70    #[must_use]
71    pub fn with_title(mut self, title: impl Into<String>) -> Self {
72        self.title = Some(title.into());
73        self
74    }
75
76    /// Set this request's body class; escaped as an attribute.
77    #[must_use]
78    pub fn with_body_class(mut self, class: impl Into<String>) -> Self {
79        self.body_class = Some(class.into());
80        self
81    }
82
83    /// Request data assigned to `window.__FORMA_CONFIG__` by a nonced script.
84    /// Static SSR routes refuse this executable configuration with `RENDER-105`.
85    #[must_use]
86    pub fn with_config_json(mut self, value: serde_json::Value) -> Self {
87        self.config_json = Some(value);
88        self
89    }
90
91    /// Request branding CSS. The document writer neutralizes style end tags.
92    #[must_use]
93    pub fn with_personality_css(mut self, css: impl Into<String>) -> Self {
94        self.personality_css = Some(css.into());
95        self
96    }
97
98    /// Explicit trusted markup before the page root; plain strings do not satisfy this API.
99    #[must_use]
100    pub fn with_body_prefix(mut self, html: crate::RawHtml) -> Self {
101        self.body_prefix = Some(html);
102        self
103    }
104
105    pub(crate) fn config_json(&self) -> Option<&serde_json::Value> {
106        self.config_json.as_ref()
107    }
108    pub(crate) fn personality_css(&self) -> Option<&str> {
109        self.personality_css.as_deref()
110    }
111    pub(crate) fn body_prefix(&self) -> Option<&crate::RawHtml> {
112        self.body_prefix.as_ref()
113    }
114
115    pub(crate) fn title(&self) -> Option<&str> {
116        self.title.as_deref()
117    }
118    pub(crate) fn body_class(&self) -> Option<&str> {
119        self.body_class.as_deref()
120    }
121
122    /// Extend this page's policy (see [`PolicyExtensions`]).
123    #[must_use]
124    pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
125        self.policy = policy;
126        self
127    }
128
129    /// Override both walk budgets for this request only. Without this call,
130    /// the handle's bind-time limits apply. Use its `limits()` value with struct
131    /// update syntax to change just one budget.
132    #[must_use]
133    pub fn with_limits(mut self, limits: WalkLimits) -> Self {
134        self.limits = Some(limits);
135        self
136    }
137
138    /// Use the bound page's limits unless this request explicitly overrides them.
139    pub(crate) fn limits(&self, inherited: WalkLimits) -> WalkLimits {
140        self.limits.unwrap_or(inherited)
141    }
142
143    /// Write the route's `modulepreload` links from the build's manifest,
144    /// each with the page's nonce, after the route's own modules. The
145    /// typed home for a preload workaround in `body_prefix`, such as
146    /// Ledger's. The table covers every route; the render takes
147    /// `config.route_pattern`'s list. Like every typed home, each call site
148    /// belongs on the application's raw-entry allowlist (`forma-raw-lint`).
149    ///
150    /// ```no_run
151    /// use forma_server::{assets, ModulePreloads, RenderCtx};
152    ///
153    /// // At start, beside the manifest; refuse to start on its error.
154    /// fn start<Assets: rust_embed::Embed>() -> Result<ModulePreloads, String> {
155    ///     assets::try_load_module_preloads::<Assets>()
156    /// }
157    ///
158    /// // Per request.
159    /// fn ctx(preloads: &ModulePreloads) -> RenderCtx {
160    ///     RenderCtx::new().with_module_preloads(preloads)
161    /// }
162    /// ```
163    ///
164    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
165    #[must_use]
166    pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
167        self.module_preloads = Some(preloads.clone());
168        self
169    }
170
171    /// Add a `<link>` to the page's head (see [`HeadLink`]), after any added
172    /// before it. The typed home for links spliced in before `</head>`; each
173    /// call site belongs on the application's raw-entry allowlist
174    /// (`forma-raw-lint`).
175    ///
176    /// Verified by: crates/forma-server/tests/internal_renderer/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
177    #[must_use]
178    pub fn with_head_link(mut self, link: HeadLink) -> Self {
179        self.head_links.push(link);
180        self
181    }
182
183    /// The route's manifest preloads, empty without a table.
184    pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
185        self.module_preloads
186            .as_ref()
187            .map_or(&[], |table| table.route(route_pattern))
188    }
189
190    /// The head links, in the order added.
191    pub(crate) fn head_links(&self) -> &[HeadLink] {
192        &self.head_links
193    }
194}
195
196/// Why a page was not rendered. Nothing reaches the browser unless the
197/// handler sends it: answer with the application's own error page.
198///
199/// Every error has a stable [`code`](RenderError::code), the
200/// [`route`](RenderError::route) it was rendering, and the
201/// [`component_path`](RenderError::component_path) where it stopped (empty
202/// when the failure is not inside a component). The codes, by family:
203///
204/// | Code | Variant | Cause |
205/// |---|---|---|
206/// | RENDER-101 | [`MissingModule`](RenderError::MissingModule) | The config has no module or no slots to render |
207/// | RENDER-104 | [`Manifest`](RenderError::Manifest) | Invalid manifest or referenced integrity |
208/// | RENDER-105 | [`StaticRoute`](RenderError::StaticRoute) | A static SSR route requires JavaScript for islands, configuration or preloads |
209/// | RENDER-103 | [`RouteMode`](RenderError::RouteMode) | A shell route is absent or not Browser |
210/// | RENDER-406 | [`Unfinalized`](RenderError::Unfinalized) | Required startup finalization is incomplete |
211/// | RENDER-102 | [`Walk`](RenderError::Walk) | The walk refused the module or its data |
212/// | 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) |
213/// | RENDER-201 | [`Policy`](RenderError::Policy) | A policy extension is not a source a page may add |
214/// | RENDER-202 | [`Nonce`](RenderError::Nonce) | The system random source failed to draw a nonce |
215/// | RENDER-301 | [`Budget`](RenderError::Budget) | The complete escaped response passed `max_output_bytes` |
216/// | RENDER-302 | [`Budget`](RenderError::Budget) | The walk's list rows passed `max_list_iterations` |
217/// | RENDER-303 | [`Budget`](RenderError::Budget) | Lists or control flow nested past the walker's depth |
218/// | RENDER-501 | [`IslandState`](RenderError::IslandState) | Declared island input mapping or value is invalid |
219/// | RENDER-502 | [`IslandState`](RenderError::IslandState) | Escaped island state exceeds one MiB |
220/// | RENDER-399 | [`Fault`](RenderError::Fault) | The render panicked; a defect, not an input |
221///
222/// A code, once published, keeps its meaning; new causes get new codes.
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[non_exhaustive]
225pub enum RenderError {
226    /// RENDER-601: a native navigation response is unsupported or malformed.
227    #[cfg(feature = "navigation")]
228    #[non_exhaustive]
229    Navigation {
230        /// The requested route.
231        route: String,
232        /// Refusal reason. No partial response is emitted.
233        message: String,
234    },
235    /// RENDER-105: a static SSR route cannot execute or preload JavaScript.
236    #[non_exhaustive]
237    StaticRoute {
238        /// The requested route.
239        route: String,
240        /// The incompatible island, configuration or preload contract.
241        message: String,
242    },
243    /// RENDER-104: manifest or referenced resource integrity is invalid.
244    #[non_exhaustive]
245    Manifest {
246        /// The requested route.
247        route: String,
248        /// Manifest validation failure or the resource whose integrity is unavailable.
249        message: String,
250    },
251    /// RENDER-103: a browser shell requires an explicitly declared browser route.
252    #[non_exhaustive]
253    RouteMode {
254        /// The requested route.
255        route: String,
256    },
257    /// RENDER-406: every SSR handler must bind and the registry must finalize first.
258    #[non_exhaustive]
259    Unfinalized {
260        /// The bound route.
261        route: String,
262    },
263    /// RENDER-401..404: a handler value violates its declared contract.
264    #[non_exhaustive]
265    Props {
266        /// Checked value error, including its declared property path and sources.
267        cause: forma_ir::props::PropsError,
268    },
269    /// RENDER-405: a development reload no longer matches the bound handler.
270    #[non_exhaustive]
271    Binding {
272        /// Bound route.
273        route: String,
274        /// Binding refusals for the changed module. No partial HTML is returned.
275        errors: Vec<forma_ir::props::PropsError>,
276    },
277    /// RENDER-101: Server rendering, and the config
278    /// carries no module or no slots for it. A browser-rendered route is
279    /// served by [`render_browser_shell`], by declaration.
280    #[non_exhaustive]
281    MissingModule {
282        /// The route being rendered.
283        route: String,
284    },
285    /// RENDER-102: the walk refused the module or the data it was given.
286    #[non_exhaustive]
287    Walk {
288        /// The route being rendered.
289        route: String,
290        /// The islands open where the walk stopped, outermost first.
291        component_path: Vec<String>,
292        /// How far the walk got.
293        counts: RenderCounts,
294        /// The walker's error.
295        cause: IrError,
296    },
297    /// RENDER-501 or RENDER-502: no partial state or page is returned.
298    #[non_exhaustive]
299    IslandState {
300        /// Route whose island state was refused.
301        route: String,
302        /// Open island registration names, outermost first.
303        component_path: Vec<String>,
304        /// Body walk work completed before refusal.
305        counts: RenderCounts,
306        /// The invalid field path or fixed state-byte ceiling.
307        cause: IrError,
308    },
309    /// RENDER-201: a policy extension was refused; there is no page to send
310    /// without its policy.
311    #[non_exhaustive]
312    Policy {
313        /// The route being rendered.
314        route: String,
315        /// The refused extension.
316        cause: PolicyError,
317    },
318    /// RENDER-202: no nonce could be drawn, so no policy could bless the page.
319    #[non_exhaustive]
320    Nonce {
321        /// The route being rendered.
322        route: String,
323        /// The failed draw.
324        cause: NonceError,
325    },
326    /// RENDER-301, RENDER-302, RENDER-303: the complete response exceeded
327    /// its output-byte limit, or the walk reached a list/depth budget.
328    #[non_exhaustive]
329    Budget {
330        /// The route being rendered.
331        route: String,
332        /// The islands open where the walk stopped, outermost first.
333        component_path: Vec<String>,
334        /// Body-only walk work, including when complete response assembly refuses.
335        counts: RenderCounts,
336        /// The output, list, or depth budget that refused the response.
337        cause: IrError,
338    },
339    /// RENDER-399: the render panicked and the panic was caught. The
340    /// process and every other request carry on, while the application
341    /// unwinds on panic; under `panic = "abort"` the process ends.
342    #[non_exhaustive]
343    Fault {
344        /// The route being rendered.
345        route: String,
346        /// The islands open where the walk panicked, when it was in the walk.
347        component_path: Vec<String>,
348        /// How far the walk got, when it was in the walk.
349        counts: RenderCounts,
350        /// The panic's message.
351        message: String,
352    },
353}
354
355impl RenderError {
356    /// The stable code, such as `RENDER-301`.
357    pub fn code(&self) -> &'static str {
358        match self {
359            #[cfg(feature = "navigation")]
360            RenderError::Navigation { .. } => "RENDER-601",
361            RenderError::StaticRoute { .. } => "RENDER-105",
362            RenderError::Manifest { .. } => "RENDER-104",
363            RenderError::RouteMode { .. } => "RENDER-103",
364            RenderError::Unfinalized { .. } => "RENDER-406",
365            RenderError::Props { cause } => cause.code(),
366            RenderError::Binding { .. } => "RENDER-405",
367            RenderError::MissingModule { .. } => "RENDER-101",
368            RenderError::Walk { cause, .. } => match cause {
369                // A published code keeps its meaning; the keyed-row refusal
370                // is a new cause with its own code (FORMA-181, DR-8).
371                IrError::InvalidListKey(_) => "RENDER-503",
372                _ => "RENDER-102",
373            },
374            RenderError::IslandState { cause, .. } => match cause {
375                IrError::IslandStateLimitExceeded { .. } => "RENDER-502",
376                IrError::InvalidIslandState(_) => "RENDER-501",
377                _ => "RENDER-501",
378            },
379            RenderError::Policy { .. } => "RENDER-201",
380            RenderError::Nonce { .. } => "RENDER-202",
381            RenderError::Budget { cause, .. } => match cause {
382                IrError::OutputLimitExceeded { .. } => "RENDER-301",
383                IrError::ListBudgetExceeded { .. } => "RENDER-302",
384                _ => "RENDER-303",
385            },
386            RenderError::Fault { .. } => "RENDER-399",
387        }
388    }
389
390    /// The route pattern the render was for.
391    pub fn route(&self) -> &str {
392        match self {
393            #[cfg(feature = "navigation")]
394            RenderError::Navigation { route, .. } => route,
395            RenderError::StaticRoute { route, .. }
396            | RenderError::Manifest { route, .. }
397            | RenderError::RouteMode { route }
398            | RenderError::Unfinalized { route } => route,
399            RenderError::Props { cause } => cause.route(),
400            RenderError::Binding { route, .. } => route,
401            RenderError::MissingModule { route }
402            | RenderError::Walk { route, .. }
403            | RenderError::IslandState { route, .. }
404            | RenderError::Policy { route, .. }
405            | RenderError::Nonce { route, .. }
406            | RenderError::Budget { route, .. }
407            | RenderError::Fault { route, .. } => route,
408        }
409    }
410
411    /// The declared property path of a checked-value refusal.
412    pub fn property_path(&self) -> Option<&str> {
413        match self {
414            Self::Props { cause } => Some(cause.path()),
415            Self::IslandState {
416                cause: IrError::InvalidIslandState(problem),
417                ..
418            } => Some(&problem.path),
419            _ => None,
420        }
421    }
422
423    /// The component names of the islands open where the render stopped,
424    /// outermost first; empty when it stopped outside every island or before
425    /// the walk. A component the compiler inlined into its parent has no name
426    /// until FMIR v3's source positions.
427    pub fn component_path(&self) -> &[String] {
428        match self {
429            RenderError::Walk { component_path, .. }
430            | RenderError::IslandState { component_path, .. }
431            | RenderError::Budget { component_path, .. }
432            | RenderError::Fault { component_path, .. } => component_path,
433            _ => &[],
434        }
435    }
436
437    /// How far the walk got, for an error raised during it.
438    pub fn counts(&self) -> Option<RenderCounts> {
439        match self {
440            RenderError::Walk { counts, .. }
441            | RenderError::IslandState { counts, .. }
442            | RenderError::Budget { counts, .. }
443            | RenderError::Fault { counts, .. } => Some(*counts),
444            _ => None,
445        }
446    }
447}
448
449impl fmt::Display for RenderError {
450    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
451        write!(f, "{} {}", self.code(), self.route())?;
452        let path = self.component_path();
453        if !path.is_empty() {
454            write!(f, " in {}", path.join(" > "))?;
455        }
456        match self {
457            #[cfg(feature = "navigation")]
458            RenderError::Navigation { message, .. } => write!(f, ": {message}"),
459            RenderError::StaticRoute { message, .. } | RenderError::Manifest { message, .. } => {
460                write!(f, ": {message}")
461            }
462            RenderError::RouteMode { .. } => {
463                write!(f, ": route is not explicitly browser-rendered")
464            }
465            RenderError::Unfinalized { .. } => write!(
466                f,
467                ": bind every SSR route and finalize Pages before serving"
468            ),
469            RenderError::Props { cause } => write!(f, ": {cause}"),
470            RenderError::Binding { errors, .. } => {
471                for error in errors {
472                    write!(f, "; {error}")?;
473                }
474                Ok(())
475            }
476            RenderError::MissingModule { .. } => {
477                write!(f, ": no module or slots to render on the server")
478            }
479            RenderError::Walk { cause, .. }
480            | RenderError::IslandState { cause, .. }
481            | RenderError::Budget { cause, .. } => {
482                write!(f, ": {cause}")
483            }
484            RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
485            RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
486            RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
487        }?;
488        if let Some(c) = self.counts() {
489            write!(
490                f,
491                " (after {} nodes, {} bytes, {} list rows)",
492                c.nodes, c.bytes, c.list_iterations
493            )?;
494        }
495        Ok(())
496    }
497}
498
499impl std::error::Error for RenderError {
500    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
501        match self {
502            RenderError::Walk { cause, .. }
503            | RenderError::IslandState { cause, .. }
504            | RenderError::Budget { cause, .. } => Some(cause),
505            RenderError::Props { cause } => Some(cause),
506            RenderError::Binding { errors, .. } => {
507                errors.first().map(|error| error as &dyn std::error::Error)
508            }
509            RenderError::Policy { cause, .. } => Some(cause),
510            RenderError::Nonce { cause, .. } => Some(cause),
511            _ => None,
512        }
513    }
514}
515
516/// The sole private SSR entry: its module and slots come from a verified handle.
517pub(crate) fn render_bound(
518    config: &PageConfig,
519    ctx: &RenderCtx,
520    module: Option<&forma_ir::IrModule>,
521    slots: Option<&forma_ir::SlotData>,
522    limits: WalkLimits,
523) -> Result<PageResponse, RenderError> {
524    render_guarded(
525        config,
526        ctx,
527        CspNonce::try_generate,
528        Root::Walk {
529            module,
530            slots,
531            limits,
532        },
533    )
534}
535
536/// Serve a route the build declares browser-rendered (`ssr: false`): the
537/// nonced bundle and the same headers as any page, around an empty root.
538///
539/// By declaration, never in place of a failure: it does not walk. Unknown and
540/// SSR routes are refused before document work. Policy, nonce and fault errors
541/// retain the same response boundary as a bound SSR page.
542///
543/// 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"
544pub fn render_browser_shell(
545    config: &PageConfig,
546    ctx: &RenderCtx,
547) -> Result<PageResponse, RenderError> {
548    if !config
549        .manifest
550        .route(config.route_pattern)
551        .is_some_and(|route| route.mode == crate::RouteMode::Browser)
552    {
553        return Err(RenderError::RouteMode {
554            route: config.route_pattern.into(),
555        });
556    }
557    config
558        .manifest
559        .validate()
560        .map_err(|message| RenderError::Manifest {
561            route: config.route_pattern.into(),
562            message,
563        })?;
564    render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
565}
566
567/// What goes in the page's root.
568#[derive(Debug, Clone, Copy)]
569enum Root<'a> {
570    /// The walk's HTML.
571    Walk {
572        module: Option<&'a forma_ir::IrModule>,
573        slots: Option<&'a forma_ir::SlotData>,
574        limits: WalkLimits,
575    },
576    /// Nothing: the browser renders.
577    Shell,
578}
579
580/// The render, with the nonce source a parameter so a test can make it fail,
581/// inside the outer `catch_unwind`.
582///
583/// The walk catches its own panic, with the component path
584/// (`forma_ir::walk_to_html_counted`); this guard covers the rest of the
585/// render, which carries no `unwrap`, `expect` or `panic!` (the crate denies
586/// them) and so has no known panic to catch.
587fn render_guarded(
588    config: &PageConfig,
589    ctx: &RenderCtx,
590    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
591    root: Root<'_>,
592) -> Result<PageResponse, RenderError> {
593    guard(config.route_pattern, || render(config, ctx, draw, root))
594}
595
596/// Catch request-side unwinds, including user-defined props conversion.
597pub(crate) fn guard<T>(
598    route: &str,
599    render: impl FnOnce() -> Result<T, RenderError>,
600) -> Result<T, RenderError> {
601    match catch_unwind(AssertUnwindSafe(render)) {
602        Ok(result) => result,
603        Err(payload) => Err(RenderError::Fault {
604            route: route.to_string(),
605            component_path: Vec::new(),
606            counts: RenderCounts::default(),
607            message: panic_message(payload.as_ref()),
608        }),
609    }
610}
611
612fn render_content(
613    config: &PageConfig,
614    ctx: &RenderCtx,
615    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
616    root: Root<'_>,
617) -> Result<RenderedContent, RenderError> {
618    let route = || config.route_pattern.to_string();
619    let static_route = config
620        .manifest
621        .route(config.route_pattern)
622        .is_some_and(crate::RouteAssets::is_static_ssr);
623    if static_route {
624        let message = if config.config_json.is_some() {
625            Some("static SSR cannot execute config_json; publish a JavaScript entry for client configuration")
626        } else if !ctx.module_preloads(config.route_pattern).is_empty()
627            || config
628                .manifest
629                .route(config.route_pattern)
630                .is_some_and(|assets| !assets.modulepreload.is_empty())
631        {
632            Some("static SSR cannot load module preloads; rebuild the route and its request preload table together")
633        } else if matches!(root, Root::Walk { module: Some(module), .. } if !module.islands.is_empty())
634        {
635            Some("static SSR contains declared islands but has no JavaScript entry")
636        } else {
637            None
638        };
639        if let Some(message) = message {
640            return Err(RenderError::StaticRoute {
641                route: route(),
642                message: message.into(),
643            });
644        }
645    }
646    let nonce = draw().map_err(|cause| RenderError::Nonce {
647        route: route(),
648        cause,
649    })?;
650    // The policy before the walk: an invalid extension costs no walk.
651    let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
652        route: route(),
653        cause,
654    })?;
655
656    let output_limit = match root {
657        Root::Walk { limits, .. } => limits.max_output_bytes,
658        Root::Shell => ctx.limits(WalkLimits::default()).max_output_bytes,
659    };
660    let (ssr_body, island_state, counts) = match root {
661        Root::Shell => (None, None, RenderCounts::default()),
662        Root::Walk {
663            module,
664            slots,
665            limits,
666        } => {
667            let (Some(module), Some(slots)) = (module, slots) else {
668                return Err(RenderError::MissingModule { route: route() });
669            };
670            let started = Instant::now();
671            let mode = if static_route {
672                forma_ir::RenderMode::Static
673            } else {
674                forma_ir::RenderMode::Adoptable
675            };
676            let walked = forma_ir::walk_to_html_counted_with_mode(module, slots, limits, mode);
677            let walk_time = started.elapsed();
678            match walked {
679                Ok(out) => (
680                    Some(out.html),
681                    (!static_route).then_some(out.island_state),
682                    RenderCounts::from_walk(out.counts, walk_time),
683                ),
684                Err(failure) => {
685                    let route = route();
686                    let component_path = failure.component_path;
687                    let counts = RenderCounts::from_walk(failure.counts, walk_time);
688                    let cause = failure.error;
689                    return Err(match cause {
690                        IrError::InvalidIslandState(_)
691                        | IrError::IslandStateLimitExceeded { .. } => RenderError::IslandState {
692                            route,
693                            component_path,
694                            counts,
695                            cause,
696                        },
697                        IrError::OutputLimitExceeded { .. }
698                        | IrError::ListBudgetExceeded { .. }
699                        | IrError::ListDepthExceeded { .. }
700                        | IrError::RecursionLimitExceeded => RenderError::Budget {
701                            route,
702                            component_path,
703                            counts,
704                            cause,
705                        },
706                        IrError::Panicked { message } => RenderError::Fault {
707                            route,
708                            component_path,
709                            counts,
710                            message,
711                        },
712                        cause => RenderError::Walk {
713                            route,
714                            component_path,
715                            counts,
716                            cause,
717                        },
718                    });
719                }
720            }
721        }
722    };
723
724    Ok(RenderedContent {
725        nonce,
726        policy,
727        ssr_body,
728        island_state,
729        counts,
730        output_limit,
731    })
732}
733
734pub(crate) struct RenderedContent {
735    pub(crate) nonce: CspNonce,
736    pub(crate) policy: csp::PagePolicy,
737    pub(crate) ssr_body: Option<String>,
738    pub(crate) island_state: Option<forma_ir::IslandState>,
739    pub(crate) counts: RenderCounts,
740    pub(crate) output_limit: usize,
741}
742
743fn render(
744    config: &PageConfig,
745    ctx: &RenderCtx,
746    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
747    root: Root<'_>,
748) -> Result<PageResponse, RenderError> {
749    let RenderedContent {
750        nonce,
751        policy,
752        ssr_body,
753        island_state,
754        counts,
755        output_limit,
756    } = render_content(config, ctx, draw, root)?;
757    let html = crate::template::write_document(
758        config,
759        &nonce,
760        ssr_body.as_deref(),
761        island_state.as_ref(),
762        ctx,
763        output_limit,
764        counts,
765    )?;
766    if let Some(reason) = policy.relaxed() {
767        csp::log_relaxation(config.route_pattern, reason);
768    }
769    let response = PageResponse::with_policy(html, nonce, policy, counts);
770    #[cfg(feature = "navigation")]
771    let response = if ctx.navigation().is_some() {
772        response.with_navigation()
773    } else {
774        response
775    };
776    Ok(response)
777}
778
779/// The experimental representation uses the exact same checked walk as documents.
780#[cfg(feature = "experimental-router")]
781pub(crate) fn render_bound_navigation(
782    config: &PageConfig,
783    ctx: &RenderCtx,
784    module: &forma_ir::IrModule,
785    slots: &forma_ir::SlotData,
786    limits: WalkLimits,
787) -> Result<crate::navigation::NavigationResponse, RenderError> {
788    crate::navigation::validate_context(config, ctx)?;
789    let content = render_content(
790        config,
791        ctx,
792        CspNonce::try_generate,
793        Root::Walk {
794            module: Some(module),
795            slots: Some(slots),
796            limits,
797        },
798    )?;
799    if let Some(reason) = content.policy.relaxed() {
800        csp::log_relaxation(config.route_pattern, reason);
801    }
802    crate::navigation::NavigationResponse::from_render(
803        config,
804        ctx,
805        content.ssr_body.as_deref().unwrap_or(""),
806        content.island_state.as_ref(),
807        content.counts,
808        content.output_limit,
809    )
810}
811
812/// Owner output is created only after the ordinary checked render and policy path.
813#[cfg(feature = "navigation")]
814pub(crate) fn render_bound_owner(
815    config: &PageConfig,
816    ctx: &RenderCtx,
817    module: &forma_ir::IrModule,
818    slots: &forma_ir::SlotData,
819    limits: WalkLimits,
820    values: &serde_json::Value,
821    reuse_key: &str,
822) -> Result<crate::NavigationOwner, RenderError> {
823    crate::navigation::validate_sealed(config, ctx)?;
824    let content = render_content(
825        config,
826        ctx,
827        CspNonce::try_generate,
828        Root::Walk {
829            module: Some(module),
830            slots: Some(slots),
831            limits,
832        },
833    )?;
834    if let Some(reason) = content.policy.relaxed() {
835        csp::log_relaxation(config.route_pattern, reason);
836    }
837    crate::NavigationOwner::from_render(config, ctx, module, values, reuse_key, content)
838}
839
840/// A panic payload's message: `panic!` with a literal gives a `&str`, with
841/// formatting a `String`.
842fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
843    if let Some(message) = payload.downcast_ref::<&str>() {
844        (*message).to_string()
845    } else if let Some(message) = payload.downcast_ref::<String>() {
846        message.clone()
847    } else {
848        "a panic with no message".to_string()
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use crate::types::AssetManifest;
856    use std::collections::HashMap;
857
858    fn manifest() -> AssetManifest {
859        AssetManifest {
860            version: 2,
861            writer: "3.0.0".into(),
862            app: "ledger".into(),
863            aliases: HashMap::new(),
864            build: "test".to_string(),
865            assets: HashMap::new(),
866            routes: HashMap::new(),
867        }
868    }
869
870    #[allow(deprecated)]
871    fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
872        PageConfig {
873            title: "Seam",
874            route_pattern: "/seam",
875            manifest,
876            config_json: None,
877            body_class: None,
878            personality_css: None,
879            body_prefix: None,
880        }
881    }
882
883    /// FORMA-181: a keyed-row refusal is a new cause of a refused walk with its own code;
884    /// every other walk refusal keeps RENDER-102.
885    #[test]
886    fn a_keyed_row_refusal_is_render_503_and_other_walk_refusals_stay_render_102() {
887        use forma_ir::{ListKeyReason, ListKeyViolation};
888        let walk = |cause: IrError| RenderError::Walk {
889            route: "/seam".into(),
890            component_path: vec!["Rows".into()],
891            counts: RenderCounts::default(),
892            cause,
893        };
894        let keyed = walk(IrError::InvalidListKey(Box::new(ListKeyViolation::new(
895            30,
896            2,
897            "user.id",
898            ListKeyReason::Duplicate { first_row: 0 },
899        ))));
900        assert_eq!(keyed.code(), "RENDER-503");
901        assert_eq!(keyed.component_path(), ["Rows".to_string()]);
902        let text = keyed.to_string();
903        assert!(
904            text.contains("RENDER-503: LIST at bytecode offset 30, row 2, key path `user.id`: the row repeats the key row 0 already rendered"),
905            "{text}"
906        );
907        assert_eq!(walk(IrError::InvalidOpcode(0xff)).code(), "RENDER-102");
908    }
909
910    #[test]
911    fn static_island_mismatch_refuses_before_nonce_or_walk() {
912        use forma_ir::parser::test_helpers::{
913            build_declared_fixture, encode_close_tag, encode_open_tag,
914        };
915        let mut manifest = manifest();
916        manifest.routes.insert(
917            "/seam".into(),
918            crate::RouteAssets {
919                #[cfg(feature = "navigation")]
920                navigation: None,
921                mode: crate::RouteMode::Ssr,
922                js: vec![],
923                css: vec![],
924                fonts: vec![],
925                modulepreload: vec![],
926                ir: Some("home.ir".into()),
927                total_size_br: 0,
928                budget_warn_threshold: 0,
929            },
930        );
931        let opcodes = [
932            vec![11, 0, 0],
933            encode_open_tag(0, &[]),
934            encode_close_tag(0),
935            vec![12, 0, 0],
936        ]
937        .concat();
938        let module = forma_ir::IrModule::parse(&build_declared_fixture(
939            &["div", "Panel"],
940            &[],
941            &opcodes,
942            &[(0, 1, 0, 1, 0, &[])],
943        ))
944        .unwrap();
945        let error = render_guarded(
946            &shell_config(&manifest),
947            &RenderCtx::new(),
948            || panic!("static refusal must precede nonce generation"),
949            Root::Walk {
950                module: Some(&module),
951                slots: None,
952                limits: WalkLimits::default(),
953            },
954        )
955        .unwrap_err();
956        assert_eq!(error.code(), "RENDER-105");
957        assert_eq!(error.route(), "/seam");
958        assert!(error.to_string().contains("declared islands"));
959        assert_eq!(error.counts(), None);
960        assert!(error.component_path().is_empty());
961    }
962
963    #[test]
964    fn render_202_a_failed_nonce_draw() {
965        // The OS random source cannot be made to fail from a test, so the
966        // render takes its draw as a parameter and this test hands it a
967        // failing one. `try_render` passes `CspNonce::try_generate`.
968        let manifest = manifest();
969        for root in [
970            Root::Walk {
971                module: None,
972                slots: None,
973                limits: WalkLimits::default(),
974            },
975            Root::Shell,
976        ] {
977            let error = render_guarded(
978                &shell_config(&manifest),
979                &RenderCtx::new(),
980                || Err(NonceError),
981                root,
982            )
983            .unwrap_err();
984            assert_eq!(error.code(), "RENDER-202");
985            assert_eq!(error.route(), "/seam");
986            assert!(error.component_path().is_empty());
987            assert_eq!(error.counts(), None);
988        }
989        // The same seam with a real draw renders: the failure is the draw's.
990        let page = render_guarded(
991            &shell_config(&manifest),
992            &RenderCtx::new(),
993            CspNonce::try_generate,
994            Root::Shell,
995        );
996        assert!(page.is_ok());
997    }
998
999    #[test]
1000    fn a_panic_outside_the_walk_is_still_a_fault() {
1001        // The outer guard: a panic before the walk (here, in the draw) comes
1002        // back as RENDER-399 too, with no component path.
1003        let manifest = manifest();
1004        let error = render_guarded(
1005            &shell_config(&manifest),
1006            &RenderCtx::new(),
1007            || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
1008            Root::Shell,
1009        )
1010        .unwrap_err();
1011        assert_eq!(error.code(), "RENDER-399");
1012        assert_eq!(error.route(), "/seam");
1013        assert!(error.component_path().is_empty());
1014        match error {
1015            RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
1016            other => panic!("expected Fault, got {other:?}"),
1017        }
1018    }
1019
1020    #[test]
1021    fn an_invalid_extension_fails_before_the_walk() {
1022        // No module at all: if the walk ran first this would be RENDER-101.
1023        let manifest = manifest();
1024        let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
1025        let error = render_bound(
1026            &shell_config(&manifest),
1027            &ctx,
1028            None,
1029            None,
1030            WalkLimits::default(),
1031        )
1032        .unwrap_err();
1033        assert_eq!(error.code(), "RENDER-201");
1034    }
1035
1036    #[test]
1037    fn display_names_code_route_component_and_counts() {
1038        let error = RenderError::Budget {
1039            route: "/t/:tenant".to_string(),
1040            component_path: vec!["Shell".to_string(), "Branding".to_string()],
1041            counts: RenderCounts {
1042                nodes: 102,
1043                bytes: 2048,
1044                list_iterations: 50,
1045                ..RenderCounts::default()
1046            },
1047            cause: IrError::ListBudgetExceeded { max: 50 },
1048        };
1049        let text = error.to_string();
1050        assert!(
1051            text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
1052            "{text}"
1053        );
1054        assert!(
1055            text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
1056            "{text}"
1057        );
1058    }
1059}