Skip to main content

forma_server/
render.rs

1//! The 0.2.2 render: a page with its counts, or a coded error the handler
2//! answers.
3//!
4//! [`try_render`] is the bridge to 3.0.0's `render` on a bound page handle:
5//! it takes today's [`PageConfig`] and returns what that `render`
6//! will return, `Result<PageResponse, RenderError>`. It sits beside the
7//! deprecated `render_page`, which keeps its Phase 1 fallback.
8
9use crate::csp::{self, CspNonce, NonceError, PolicyError, PolicyExtensions};
10use crate::head::{HeadLink, ModulePreloads};
11use crate::response::{PageResponse, RenderCounts};
12use crate::types::PageConfig;
13use forma_ir::{IrError, WalkLimits};
14use std::fmt;
15use std::panic::{catch_unwind, AssertUnwindSafe};
16use std::time::Instant;
17
18/// What a render takes beyond [`PageConfig`]: the policy's typed extensions,
19/// the walk's budgets, and the typed homes for what applications used to put
20/// in `body_prefix`: the route's `modulepreload` links from the
21/// build's manifest, and head links. Never a nonce: the render draws one per
22/// response. Data goes through [`PageConfig::config_json`].
23///
24/// ```
25/// use forma_server::{HeadLink, PolicyExtensions, RenderCtx, WalkLimits};
26///
27/// let ctx = RenderCtx::new()
28///     .with_policy(PolicyExtensions::new().img_src("https:"))
29///     .with_limits(WalkLimits { max_list_iterations: 100_000, ..WalkLimits::default() })
30///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?);
31/// # let _ = ctx;
32/// # Ok::<(), forma_server::HeadLinkError>(())
33/// ```
34#[derive(Debug, Clone, Default)]
35pub struct RenderCtx {
36    policy: PolicyExtensions,
37    limits: WalkLimits,
38    module_preloads: Option<ModulePreloads>,
39    head_links: Vec<HeadLink>,
40}
41
42impl RenderCtx {
43    /// The strict policy and the walker's default budgets (8 MiB of output,
44    /// 50,000 list rows).
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Extend this page's policy (see [`PolicyExtensions`]).
50    #[must_use]
51    pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
52        self.policy = policy;
53        self
54    }
55
56    /// Budget this page's walk differently from the defaults.
57    #[must_use]
58    pub fn with_limits(mut self, limits: WalkLimits) -> Self {
59        self.limits = limits;
60        self
61    }
62
63    /// Write the route's `modulepreload` links from the build's manifest,
64    /// each with the page's nonce, after the route's own modules. The
65    /// typed home for a preload workaround in `body_prefix`, such as
66    /// Ledger's. The table covers every route; the render takes
67    /// `config.route_pattern`'s list. Like every typed home, each call site
68    /// belongs on the application's raw-entry allowlist (`forma-raw-lint`).
69    ///
70    /// ```no_run
71    /// use forma_server::{assets, ModulePreloads, RenderCtx};
72    ///
73    /// // At start, beside the manifest; refuse to start on its error.
74    /// fn start<Assets: rust_embed::Embed>() -> Result<ModulePreloads, String> {
75    ///     assets::try_load_module_preloads::<Assets>()
76    /// }
77    ///
78    /// // Per request.
79    /// fn ctx(preloads: &ModulePreloads) -> RenderCtx {
80    ///     RenderCtx::new().with_module_preloads(preloads)
81    /// }
82    /// ```
83    ///
84    /// Verified by: crates/forma-server/tests/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
85    #[must_use]
86    pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
87        self.module_preloads = Some(preloads.clone());
88        self
89    }
90
91    /// Add a `<link>` to the page's head (see [`HeadLink`]), after any added
92    /// before it. The typed home for links spliced in before `</head>`; each
93    /// call site belongs on the application's raw-entry allowlist
94    /// (`forma-raw-lint`).
95    ///
96    /// Verified by: crates/forma-server/tests/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
97    #[must_use]
98    pub fn with_head_link(mut self, link: HeadLink) -> Self {
99        self.head_links.push(link);
100        self
101    }
102
103    /// The route's manifest preloads, empty without a table.
104    pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
105        self.module_preloads
106            .as_ref()
107            .map_or(&[], |table| table.route(route_pattern))
108    }
109
110    /// The head links, in the order added.
111    pub(crate) fn head_links(&self) -> &[HeadLink] {
112        &self.head_links
113    }
114}
115
116/// Why a page was not rendered. Nothing reaches the browser unless the
117/// handler sends it: answer with the application's own error page.
118///
119/// Every error has a stable [`code`](RenderError::code), the
120/// [`route`](RenderError::route) it was rendering, and the
121/// [`component_path`](RenderError::component_path) where it stopped (empty
122/// when the failure is not inside a component). The codes, by family:
123///
124/// | Code | Variant | Cause |
125/// |---|---|---|
126/// | RENDER-101 | [`MissingModule`](RenderError::MissingModule) | The config has no module or no slots to render |
127/// | RENDER-102 | [`Walk`](RenderError::Walk) | The walk refused the module or its data |
128/// | RENDER-201 | [`Policy`](RenderError::Policy) | A policy extension is not a source a page may add |
129/// | RENDER-202 | [`Nonce`](RenderError::Nonce) | The system random source failed to draw a nonce |
130/// | RENDER-301 | [`Budget`](RenderError::Budget) | The walk's output passed `max_output_bytes` |
131/// | RENDER-302 | [`Budget`](RenderError::Budget) | The walk's list rows passed `max_list_iterations` |
132/// | RENDER-303 | [`Budget`](RenderError::Budget) | Lists or control flow nested past the walker's depth |
133/// | RENDER-399 | [`Fault`](RenderError::Fault) | The render panicked; a defect, not an input |
134///
135/// A code, once published, keeps its meaning; new causes get new codes.
136#[derive(Debug, Clone, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum RenderError {
139    /// RENDER-101: [`try_render`] renders on the server, and the config
140    /// carries no module or no slots for it. A browser-rendered route is
141    /// served by [`render_browser_shell`], by declaration.
142    #[non_exhaustive]
143    MissingModule {
144        /// The route being rendered.
145        route: String,
146    },
147    /// RENDER-102: the walk refused the module or the data it was given.
148    #[non_exhaustive]
149    Walk {
150        /// The route being rendered.
151        route: String,
152        /// The islands open where the walk stopped, outermost first.
153        component_path: Vec<String>,
154        /// How far the walk got.
155        counts: RenderCounts,
156        /// The walker's error.
157        cause: IrError,
158    },
159    /// RENDER-201: a policy extension was refused; there is no page to send
160    /// without its policy.
161    #[non_exhaustive]
162    Policy {
163        /// The route being rendered.
164        route: String,
165        /// The refused extension.
166        cause: PolicyError,
167    },
168    /// RENDER-202: no nonce could be drawn, so no policy could bless the page.
169    #[non_exhaustive]
170    Nonce {
171        /// The route being rendered.
172        route: String,
173        /// The failed draw.
174        cause: NonceError,
175    },
176    /// RENDER-301, RENDER-302, RENDER-303: the walk reached one of its
177    /// budgets, and stopped there instead of running on.
178    #[non_exhaustive]
179    Budget {
180        /// The route being rendered.
181        route: String,
182        /// The islands open where the walk stopped, outermost first.
183        component_path: Vec<String>,
184        /// How far the walk got before the budget stopped it.
185        counts: RenderCounts,
186        /// The budget the walker reports.
187        cause: IrError,
188    },
189    /// RENDER-399: the render panicked and the panic was caught. The
190    /// process and every other request carry on, while the application
191    /// unwinds on panic; under `panic = "abort"` the process ends.
192    #[non_exhaustive]
193    Fault {
194        /// The route being rendered.
195        route: String,
196        /// The islands open where the walk panicked, when it was in the walk.
197        component_path: Vec<String>,
198        /// How far the walk got, when it was in the walk.
199        counts: RenderCounts,
200        /// The panic's message.
201        message: String,
202    },
203}
204
205impl RenderError {
206    /// The stable code, such as `RENDER-301`.
207    pub fn code(&self) -> &'static str {
208        match self {
209            RenderError::MissingModule { .. } => "RENDER-101",
210            RenderError::Walk { .. } => "RENDER-102",
211            RenderError::Policy { .. } => "RENDER-201",
212            RenderError::Nonce { .. } => "RENDER-202",
213            RenderError::Budget { cause, .. } => match cause {
214                IrError::OutputLimitExceeded { .. } => "RENDER-301",
215                IrError::ListBudgetExceeded { .. } => "RENDER-302",
216                _ => "RENDER-303",
217            },
218            RenderError::Fault { .. } => "RENDER-399",
219        }
220    }
221
222    /// The route pattern the render was for.
223    pub fn route(&self) -> &str {
224        match self {
225            RenderError::MissingModule { route }
226            | RenderError::Walk { route, .. }
227            | RenderError::Policy { route, .. }
228            | RenderError::Nonce { route, .. }
229            | RenderError::Budget { route, .. }
230            | RenderError::Fault { route, .. } => route,
231        }
232    }
233
234    /// The component names of the islands open where the render stopped,
235    /// outermost first; empty when it stopped outside every island or before
236    /// the walk. A component the compiler inlined into its parent has no name
237    /// until FMIR v3's source positions.
238    pub fn component_path(&self) -> &[String] {
239        match self {
240            RenderError::Walk { component_path, .. }
241            | RenderError::Budget { component_path, .. }
242            | RenderError::Fault { component_path, .. } => component_path,
243            _ => &[],
244        }
245    }
246
247    /// How far the walk got, for an error raised during it.
248    pub fn counts(&self) -> Option<RenderCounts> {
249        match self {
250            RenderError::Walk { counts, .. }
251            | RenderError::Budget { counts, .. }
252            | RenderError::Fault { counts, .. } => Some(*counts),
253            _ => None,
254        }
255    }
256}
257
258impl fmt::Display for RenderError {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "{} {}", self.code(), self.route())?;
261        let path = self.component_path();
262        if !path.is_empty() {
263            write!(f, " in {}", path.join(" > "))?;
264        }
265        match self {
266            RenderError::MissingModule { .. } => {
267                write!(f, ": no module or slots to render on the server")
268            }
269            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => {
270                write!(f, ": {cause}")
271            }
272            RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
273            RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
274            RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
275        }?;
276        if let Some(c) = self.counts() {
277            write!(
278                f,
279                " (after {} nodes, {} bytes, {} list rows)",
280                c.nodes, c.bytes, c.list_iterations
281            )?;
282        }
283        Ok(())
284    }
285}
286
287impl std::error::Error for RenderError {
288    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
289        match self {
290            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => Some(cause),
291            RenderError::Policy { cause, .. } => Some(cause),
292            RenderError::Nonce { cause, .. } => Some(cause),
293            _ => None,
294        }
295    }
296}
297
298/// Render a page on the server: the page with its counts, or why not.
299///
300/// The bridge to 3.0.0's `render`. Unlike the deprecated
301/// `render_page`, it never substitutes an empty shell for a failure: a
302/// missing module, a walk the module or data fails, an exceeded budget, an
303/// invalid policy extension, a failed nonce draw and a panic each come back
304/// as a [`RenderError`] with a stable code, the route and the component
305/// path. The page is written into its own buffer and returned whole, or not
306/// at all, so no partial page leaves. Forma logs none of these: the handler
307/// chooses the response. (A caught panic still runs the process's panic hook
308/// first: the default hook prints it to stderr, and a reporting hook, such as
309/// an error tracker's, records it.)
310///
311/// It always renders on the server: `config.render_mode` steers only the
312/// deprecated `render_page`. A route the build declares `ssr: false` is
313/// served by [`render_browser_shell`].
314///
315/// ```no_run
316/// use axum::http::StatusCode;
317/// use axum::response::{IntoResponse, Response};
318/// use forma_server::{try_render, PageConfig, RenderCtx};
319///
320/// fn home(config: &PageConfig) -> Response {
321///     match try_render(config, &RenderCtx::new()) {
322///         Ok(page) => page.into_response(), // no-store, policy, headers
323///         Err(error) => {
324///             // Count it by code and route; serve the application's error page.
325///             eprintln!("{error}");
326///             StatusCode::INTERNAL_SERVER_ERROR.into_response()
327///         }
328///     }
329/// }
330/// ```
331///
332/// # Threads and panics
333///
334/// The walk is synchronous work on the caller's thread, and forma-server
335/// starts nothing and depends on no async runtime. At the measured 12.5
336/// million nodes a second, a page past about 12,500 nodes holds the thread
337/// past a millisecond; [`RenderCounts`] reports each page's nodes and walk
338/// time, so a handler whose counts show that can move the render into tokio's
339/// blocking pool itself (owned inputs, since the closure must be `'static`):
340///
341/// ```no_run
342/// # use std::sync::Arc;
343/// # use forma_server::{try_render, AssetManifest, PageConfig, RenderCtx, RenderMode, PageResponse, RenderError};
344/// # use forma_ir::{IrModule, SlotData};
345/// # #[allow(deprecated)]
346/// async fn heavy(
347///     manifest: Arc<AssetManifest>,
348///     module: Arc<IrModule>,
349///     slots: SlotData,
350/// ) -> Result<PageResponse, RenderError> {
351///     tokio::task::spawn_blocking(move || {
352///         # #[allow(deprecated)]
353///         let config = PageConfig {
354///             title: "Ledger",
355///             route_pattern: "/ledger",
356///             manifest: &manifest,
357///             config_script: None,
358///             config_json: None,
359///             body_class: None,
360///             personality_css: None,
361///             body_prefix: None,
362///             render_mode: RenderMode::Phase2SsrReconcile,
363///             ir_module: Some(&module),
364///             slots: Some(&slots),
365///         };
366///         try_render(&config, &RenderCtx::new())
367///     })
368///     .await
369///     .expect("the blocking task ran")
370/// }
371/// ```
372///
373/// A panic inside the render is caught and returned as
374/// [`RenderError::Fault`], which holds only while the application
375/// unwinds on panic, as both templates leave it. Under `panic = "abort"` a
376/// fault still ends the process with every request in flight.
377///
378/// Verified by: crates/forma-server/tests/try_render.rs > "a_panicking_component_returns_fault_the_handler_answers_500_and_the_next_request_renders"
379pub fn try_render(config: &PageConfig, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
380    render_guarded(config, ctx, CspNonce::try_generate, Root::Walk)
381}
382
383/// Serve a route the build declares browser-rendered (`ssr: false`): the
384/// nonced bundle and the same headers as any page, around an empty root.
385///
386/// By declaration, never in place of a failure: it does not walk,
387/// even when the config carries a module. Fails only as a page fails before
388/// its walk: an invalid policy extension, a failed nonce draw, or a fault.
389///
390/// Verified by: crates/forma-server/tests/try_render.rs > "render_browser_shell_serves_the_bundle_around_an_empty_root_with_the_page_headers"
391pub fn render_browser_shell(
392    config: &PageConfig,
393    ctx: &RenderCtx,
394) -> Result<PageResponse, RenderError> {
395    render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
396}
397
398/// What goes in the page's root.
399#[derive(Debug, Clone, Copy)]
400enum Root {
401    /// The walk's HTML.
402    Walk,
403    /// Nothing: the browser renders.
404    Shell,
405}
406
407/// The render, with the nonce source a parameter so a test can make it fail,
408/// inside the outer `catch_unwind`.
409///
410/// The walk catches its own panic, with the component path
411/// (`forma_ir::walk_to_html_counted`); this guard covers the rest of the
412/// render, which carries no `unwrap`, `expect` or `panic!` (the crate denies
413/// them) and so has no known panic to catch.
414fn render_guarded(
415    config: &PageConfig,
416    ctx: &RenderCtx,
417    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
418    root: Root,
419) -> Result<PageResponse, RenderError> {
420    match catch_unwind(AssertUnwindSafe(|| render(config, ctx, draw, root))) {
421        Ok(result) => result,
422        Err(payload) => Err(RenderError::Fault {
423            route: config.route_pattern.to_string(),
424            component_path: Vec::new(),
425            counts: RenderCounts::default(),
426            message: panic_message(payload.as_ref()),
427        }),
428    }
429}
430
431fn render(
432    config: &PageConfig,
433    ctx: &RenderCtx,
434    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
435    root: Root,
436) -> Result<PageResponse, RenderError> {
437    let route = || config.route_pattern.to_string();
438    let nonce = draw().map_err(|cause| RenderError::Nonce {
439        route: route(),
440        cause,
441    })?;
442    // The policy before the walk: an invalid extension costs no walk.
443    let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
444        route: route(),
445        cause,
446    })?;
447
448    let (ssr_body, counts) = match root {
449        Root::Shell => (None, RenderCounts::default()),
450        Root::Walk => {
451            let (Some(module), Some(slots)) = (config.ir_module, config.slots) else {
452                return Err(RenderError::MissingModule { route: route() });
453            };
454            let started = Instant::now();
455            let walked = forma_ir::walk_to_html_counted(module, slots, ctx.limits);
456            let walk_time = started.elapsed();
457            match walked {
458                Ok(out) => (
459                    Some(out.html),
460                    RenderCounts::from_walk(out.counts, walk_time),
461                ),
462                Err(failure) => {
463                    let route = route();
464                    let component_path = failure.component_path;
465                    let counts = RenderCounts::from_walk(failure.counts, walk_time);
466                    let cause = failure.error;
467                    return Err(match cause {
468                        IrError::OutputLimitExceeded { .. }
469                        | IrError::ListBudgetExceeded { .. }
470                        | IrError::ListDepthExceeded { .. }
471                        | IrError::RecursionLimitExceeded => RenderError::Budget {
472                            route,
473                            component_path,
474                            counts,
475                            cause,
476                        },
477                        IrError::Panicked { message } => RenderError::Fault {
478                            route,
479                            component_path,
480                            counts,
481                            message,
482                        },
483                        cause => RenderError::Walk {
484                            route,
485                            component_path,
486                            counts,
487                            cause,
488                        },
489                    });
490                }
491            }
492        }
493    };
494
495    let html = crate::template::write_document(config, &nonce, ssr_body.as_deref(), ctx);
496    if let Some(reason) = policy.relaxed() {
497        csp::log_relaxation(Some(config.route_pattern), reason);
498    }
499    Ok(PageResponse::with_policy(html, nonce, policy, counts))
500}
501
502/// A panic payload's message: `panic!` with a literal gives a `&str`, with
503/// formatting a `String`.
504fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
505    if let Some(message) = payload.downcast_ref::<&str>() {
506        (*message).to_string()
507    } else if let Some(message) = payload.downcast_ref::<String>() {
508        message.clone()
509    } else {
510        "a panic with no message".to_string()
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::types::{AssetManifest, RenderMode};
518    use std::collections::HashMap;
519
520    #[allow(deprecated)] // `wasm` is named by every 0.2.x `AssetManifest` literal (DR-23)
521    fn manifest() -> AssetManifest {
522        AssetManifest {
523            version: 1,
524            build_hash: "test".to_string(),
525            assets: HashMap::new(),
526            routes: HashMap::new(),
527            wasm: None,
528        }
529    }
530
531    #[allow(deprecated)]
532    fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
533        PageConfig {
534            title: "Seam",
535            route_pattern: "/seam",
536            manifest,
537            config_script: None,
538            config_json: None,
539            body_class: None,
540            personality_css: None,
541            body_prefix: None,
542            render_mode: RenderMode::Phase1ClientMount,
543            ir_module: None,
544            slots: None,
545        }
546    }
547
548    #[test]
549    fn render_202_a_failed_nonce_draw() {
550        // The OS random source cannot be made to fail from a test, so the
551        // render takes its draw as a parameter and this test hands it a
552        // failing one. `try_render` passes `CspNonce::try_generate`.
553        let manifest = manifest();
554        for root in [Root::Walk, Root::Shell] {
555            let error = render_guarded(
556                &shell_config(&manifest),
557                &RenderCtx::new(),
558                || Err(NonceError),
559                root,
560            )
561            .unwrap_err();
562            assert_eq!(error.code(), "RENDER-202");
563            assert_eq!(error.route(), "/seam");
564            assert!(error.component_path().is_empty());
565            assert_eq!(error.counts(), None);
566        }
567        // The same seam with a real draw renders: the failure is the draw's.
568        let page = render_guarded(
569            &shell_config(&manifest),
570            &RenderCtx::new(),
571            CspNonce::try_generate,
572            Root::Shell,
573        );
574        assert!(page.is_ok());
575    }
576
577    #[test]
578    fn a_panic_outside_the_walk_is_still_a_fault() {
579        // The outer guard: a panic before the walk (here, in the draw) comes
580        // back as RENDER-399 too, with no component path.
581        let manifest = manifest();
582        let error = render_guarded(
583            &shell_config(&manifest),
584            &RenderCtx::new(),
585            || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
586            Root::Shell,
587        )
588        .unwrap_err();
589        assert_eq!(error.code(), "RENDER-399");
590        assert_eq!(error.route(), "/seam");
591        assert!(error.component_path().is_empty());
592        match error {
593            RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
594            other => panic!("expected Fault, got {other:?}"),
595        }
596    }
597
598    #[test]
599    fn an_invalid_extension_fails_before_the_walk() {
600        // No module at all: if the walk ran first this would be RENDER-101.
601        let manifest = manifest();
602        let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
603        let error = try_render(&shell_config(&manifest), &ctx).unwrap_err();
604        assert_eq!(error.code(), "RENDER-201");
605    }
606
607    #[test]
608    fn display_names_code_route_component_and_counts() {
609        let error = RenderError::Budget {
610            route: "/t/:tenant".to_string(),
611            component_path: vec!["Shell".to_string(), "Branding".to_string()],
612            counts: RenderCounts {
613                nodes: 102,
614                bytes: 2048,
615                list_iterations: 50,
616                ..RenderCounts::default()
617            },
618            cause: IrError::ListBudgetExceeded { max: 50 },
619        };
620        let text = error.to_string();
621        assert!(
622            text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
623            "{text}"
624        );
625        assert!(
626            text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
627            "{text}"
628        );
629    }
630}