forma_server/response.rs
1//! The page response: HTML that never leaves without its policy.
2
3use crate::csp::{CspNonce, PagePolicy};
4use axum::http::{header, HeaderName, HeaderValue};
5use axum::response::{IntoResponse, Response};
6use std::time::Duration;
7
8/// A rendered page, ready to send.
9///
10/// As axum's [`IntoResponse`] it always writes, together with the HTML:
11///
12/// - `Cache-Control: no-store`, because the HTML embeds a nonce minted for
13/// this one response; a cached copy would hand that nonce to every later
14/// reader, and another user's page with it;
15/// - `Content-Security-Policy`, the strict policy for that nonce
16/// ([`crate::csp::build_csp_header`]) with only the handler's typed
17/// [`crate::PolicyExtensions`] added;
18/// - Trusted Types enforcement in the strict policy, in every build and without
19/// a reporting endpoint. The named route relaxation moves only those two
20/// directives to `Content-Security-Policy-Report-Only`;
21/// - Optional reporting through [`crate::PolicyExtensions::report_to`] or
22/// [`crate::PolicyExtensions::report_uri`]. Strict pages also carry a diagnostic
23/// Report-Only policy in debug builds; a relaxed route always carries it;
24/// - `Cross-Origin-Opener-Policy: same-origin` (or `same-origin-allow-popups`
25/// when the extensions ask for it), `X-Content-Type-Options`,
26/// `Referrer-Policy` and `Permissions-Policy`;
27/// - `Content-Type: text/html; charset=utf-8`.
28///
29/// A handler cannot take the HTML out and send it some other way except
30/// through [`PageResponse::into_parts_unchecked`], the one named exit, which
31/// a consumer's CI can search for:
32///
33/// ```compile_fail,E0616
34/// # fn page() -> forma_server::PageResponse { unimplemented!() }
35/// let html = page().html; // private: the HTML leaves only with its headers
36/// ```
37///
38/// ```no_run
39/// # fn page() -> forma_server::PageResponse { unimplemented!() }
40/// let parts = page().into_parts_unchecked(); // the named, searchable exit
41/// # let _ = parts.html;
42/// ```
43///
44/// Verified by: crates/forma-server/tests/internal_renderer/page_response.rs > "page_response_writes_no_store_the_policy_and_the_document_headers"
45#[derive(Debug)]
46pub struct PageResponse {
47 html: String,
48 nonce: CspNonce,
49 policy: PagePolicy,
50 counts: RenderCounts,
51}
52
53/// What one render did, for the application's own logs and metrics.
54///
55/// On a [`crate::RenderError`] for a budget or a fault, the same counts say
56/// how far the walk got. For a page served without a walk (a browser
57/// shell) every count is zero. `nodes` and `list_iterations` are counted by
58/// [`crate::PageHandle::render`].
59///
60/// Walk time excludes checked props conversion and document assembly. Measure
61/// the complete handle call when deciding to move owned request data and a
62/// cloned handle into the application runtime's blocking pool.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct RenderCounts {
66 /// Elements and text nodes the walk wrote (hydration markers and
67 /// comments are not nodes).
68 pub nodes: usize,
69 /// Bytes of HTML the walk wrote into the page's root. State and document
70 /// markup are excluded from this work counter, but the complete response
71 /// is also checked against `WalkLimits::max_output_bytes` before allocation.
72 pub bytes: usize,
73 /// List rows the walk wrote, across every nesting depth, the quantity
74 /// `WalkLimits::max_list_iterations` bounds.
75 pub list_iterations: usize,
76 /// Time spent in the walk.
77 pub walk_time: Duration,
78}
79
80impl RenderCounts {
81 /// The walker's counts, with the time the walk took.
82 pub(crate) fn from_walk(counts: forma_ir::WalkCounts, walk_time: Duration) -> Self {
83 RenderCounts {
84 nodes: counts.nodes,
85 bytes: counts.bytes,
86 list_iterations: counts.list_iterations,
87 walk_time,
88 }
89 }
90}
91
92/// A page's raw parts, from [`PageResponse::into_parts_unchecked`].
93///
94/// Whoever sends these owns what [`PageResponse`] guaranteed: every header
95/// in `headers` goes out with `html`, and `html` is never cached or sent to a
96/// second request.
97#[derive(Debug)]
98#[non_exhaustive]
99pub struct PageParts {
100 /// The complete HTML document.
101 pub html: String,
102 /// The nonce `html` carries and the policy in `headers` blesses.
103 pub nonce: CspNonce,
104 /// Every header the response would have written, in its order.
105 pub headers: Vec<(HeaderName, HeaderValue)>,
106 /// What the render did.
107 pub counts: RenderCounts,
108}
109
110impl PageResponse {
111 /// Wrap a rendered document whose policy was built, from the same nonce,
112 /// before the walk (so an invalid extension costs no walk).
113 pub(crate) fn with_policy(
114 html: String,
115 nonce: CspNonce,
116 policy: PagePolicy,
117 counts: RenderCounts,
118 ) -> Self {
119 PageResponse {
120 html,
121 nonce,
122 policy,
123 counts,
124 }
125 }
126
127 /// What the render did: nodes, bytes and list rows walked, and time spent.
128 pub fn counts(&self) -> RenderCounts {
129 self.counts
130 }
131
132 /// The HTML, its nonce and every header, apart.
133 ///
134 /// The one way to reach the HTML without sending it as this response. It
135 /// exists for the rare handler that must assemble its own response (a
136 /// streaming body, a framework other than axum); each call site belongs
137 /// on the application's raw-entry allowlist (`forma-raw-lint`) with an
138 /// owner.
139 pub fn into_parts_unchecked(self) -> PageParts {
140 let headers = self.headers();
141 PageParts {
142 html: self.html,
143 nonce: self.nonce,
144 headers,
145 counts: self.counts,
146 }
147 }
148
149 /// The headers this response writes, in order.
150 fn headers(&self) -> Vec<(HeaderName, HeaderValue)> {
151 let mut headers = Vec::with_capacity(9);
152 headers.push((
153 header::CONTENT_TYPE,
154 HeaderValue::from_static("text/html; charset=utf-8"),
155 ));
156 headers.push((header::CACHE_CONTROL, HeaderValue::from_static("no-store")));
157 headers.push((header::CONTENT_SECURITY_POLICY, self.policy.csp().clone()));
158 if let Some(report_only) = self.policy.report_only() {
159 headers.push((
160 header::CONTENT_SECURITY_POLICY_REPORT_ONLY,
161 report_only.clone(),
162 ));
163 }
164 if let Some(endpoints) = self.policy.reporting_endpoints() {
165 headers.push((
166 HeaderName::from_static("reporting-endpoints"),
167 endpoints.clone(),
168 ));
169 }
170 for (name, value) in crate::types::document_security_headers() {
171 // The opener policy is the page's own: the extensions may ask for
172 // `same-origin-allow-popups`, and a second COOP header would leave
173 // the browser to pick one.
174 if name.as_str() != "cross-origin-opener-policy" {
175 headers.push((name.clone(), value.clone()));
176 }
177 }
178 headers.push((
179 HeaderName::from_static("cross-origin-opener-policy"),
180 self.policy.opener().clone(),
181 ));
182 headers
183 }
184}
185
186impl IntoResponse for PageResponse {
187 fn into_response(self) -> Response {
188 let headers = self.headers();
189 let mut response = Response::new(axum::body::Body::from(self.html));
190 let map = response.headers_mut();
191 for (name, value) in headers {
192 map.append(name, value);
193 }
194 response
195 }
196}