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}