forma_server/types.rs
1use serde::Deserialize;
2use std::collections::HashMap;
3
4/// Per-route render strategy.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum RenderMode {
7 /// Phase 1: empty `<div id="app"></div>`, client JS mounts from scratch.
8 Phase1ClientMount,
9 /// Phase 2: server renders HTML into `#app` via IR walker, client reconciles.
10 Phase2SsrReconcile,
11}
12
13/// Configuration for rendering an HTML page with external assets.
14#[derive(Debug)]
15pub struct PageConfig<'a> {
16 /// Text for the page's `<title>`, HTML-escaped on the way out.
17 pub title: &'a str,
18 /// The route this page was matched by, used to look up its assets in the
19 /// manifest (e.g. `/users/:id`).
20 pub route_pattern: &'a str,
21 /// Build manifest naming the content-hashed JS/CSS/font/IR files.
22 pub manifest: &'a AssetManifest,
23 /// Optional inline `<script>` content (e.g. `window.__CONFIG__ = {...}`).
24 ///
25 /// **RAW, TRUSTED ESCAPE HATCH.** The content is emitted into an inline
26 /// `<script>` with no JavaScript escaping. `</script` and `<!--` are
27 /// neutralized so the value cannot break out of the element, but that is
28 /// not the same as making it safe: a `"` or `\` arriving from untrusted
29 /// data still yields nonce-blessed script execution inside your own page.
30 ///
31 /// Deprecated: data goes through [`PageConfig::config_json`] as
32 /// escaped JSON, and code goes into the page's bundle, reading a flag from
33 /// that data. Set this to `None`; 3.0.0 removes the field.
34 #[deprecated(
35 since = "0.2.2",
36 note = "writes handler text into a nonced script; pass data through \
37 `config_json` and move code into the bundle. Removed in 3.0.0"
38 )]
39 pub config_script: Option<&'a str>,
40 /// Optional server data to expose to the client as
41 /// `window.__FORMA_CONFIG__`.
42 ///
43 /// This is the safe path: the value is serialized by `serde_json` with
44 /// every `<` escaped, which cannot be misused no matter what the data
45 /// contains. Prefer it over [`PageConfig::config_script`].
46 pub config_json: Option<&'a serde_json::Value>,
47 /// Optional `class` attribute for `<body>`, HTML-escaped.
48 pub body_class: Option<&'a str>,
49 /// Optional inline `<style>` for personality/theme overrides.
50 ///
51 /// Emitted inside a `<style>` element. `</` is neutralized (as a CSS
52 /// escape, losslessly) so the value cannot break out; everything else is
53 /// passed through as CSS.
54 pub personality_css: Option<&'a str>,
55 /// Optional extra HTML before `<div id="app">`.
56 ///
57 /// **RAW, TRUSTED HTML.** Interpolated verbatim with no escaping — that is
58 /// the point of the field (callers use it to emit their own markup, such
59 /// as a hand-written shell or a logo). Never build it from untrusted data.
60 ///
61 /// Pass a [`RawHtml`](crate::RawHtml), the one named raw-HTML type, bound
62 /// first and borrowed: `let logo = RawHtml::trusted(..);` then
63 /// `body_prefix: Some(&logo)`, so review and CI find every raw entry by
64 /// its constructor; the line compiles in 3.0.0 too, where the
65 /// field takes only a `RawHtml`. A plain string still works and is written
66 /// exactly as before; in debug builds the first one a process renders
67 /// logs `RAW-101` once.
68 ///
69 /// What is not the application's own markup has a typed home instead:
70 /// `modulepreload` links come from the build's manifest
71 /// ([`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads)),
72 /// head links from [`RenderCtx::with_head_link`](crate::RenderCtx::with_head_link),
73 /// and data from [`PageConfig::config_json`].
74 pub body_prefix: Option<&'a str>,
75 /// Whether to server-render the body or ship an empty `#app` shell.
76 pub render_mode: RenderMode,
77 /// Pre-parsed IR module for Phase 2 SSR
78 pub ir_module: Option<&'a forma_ir::parser::IrModule>,
79 /// Slot data for Phase 2 SSR
80 pub slots: Option<&'a forma_ir::slot::SlotData>,
81}
82
83/// Output from render_page: HTML body + CSP header value + the nonce binding
84/// them together.
85///
86/// The HTML and the CSP header are a single unit — the HTML embeds a
87/// per-request nonce that only this policy blesses. Serve them together with
88/// `Cache-Control: no-store` and never cache the HTML across requests: a
89/// cached body with a fresh policy kills every script, and caching both ships
90/// one static, publicly-known nonce to every user, which defeats nonce-based
91/// CSP entirely.
92///
93/// Superseded by [`PageResponse`](crate::PageResponse), which writes those
94/// headers itself and cannot be sent without them. Turn a
95/// `PageOutput` into one with [`PageOutput::into_page_response`]; the public
96/// fields and [`PageOutput::headers`] are deprecated and go private in 3.0.0.
97#[non_exhaustive]
98#[derive(Debug)]
99pub struct PageOutput {
100 /// The complete HTML document.
101 #[deprecated(
102 since = "0.2.2",
103 note = "send the page as a `PageResponse` (`into_page_response`), which carries \
104 `no-store` and the policy with it. Private in 3.0.0"
105 )]
106 pub html: String,
107 /// The `Content-Security-Policy` header value that blesses it.
108 #[deprecated(
109 since = "0.2.2",
110 note = "the policy is typed now: extend it with `PolicyExtensions` and send a \
111 `PageResponse` rather than editing this text. Private in 3.0.0"
112 )]
113 pub csp: String,
114 /// The nonce embedded in `html` and blessed by `csp`.
115 ///
116 /// Exposed so a caller can add its own nonced `<script>`/`<style>`, or
117 /// re-stamp a cached shell, without string-parsing the CSP.
118 #[deprecated(
119 since = "0.2.2",
120 note = "render with `try_render` (0.2.2), which draws the nonce and writes it into \
121 the page and its policy; a handler that must read it takes \
122 `PageResponse::into_parts_unchecked().nonce`. Private in 3.0.0, which gives \
123 no caller a nonce"
124 )]
125 pub nonce: crate::csp::CspNonce,
126 /// What the render did, carried into [`PageOutput::into_page_response`].
127 pub(crate) counts: crate::response::RenderCounts,
128}
129
130#[allow(deprecated)] // this impl is the bridge from the deprecated fields
131impl PageOutput {
132 /// Response headers for this page: content type, `no-store`, the CSP, and
133 /// (from 0.2.2, in development builds) Trusted Types in
134 /// `Content-Security-Policy-Report-Only`.
135 ///
136 /// Reads the (public, mutable) `csp` field, so a caller that must rewrite
137 /// a directive can do so in place first and still use this helper. An
138 /// invalid `csp` is left out rather than reported; that silent drop is
139 /// one of the reasons this is deprecated.
140 #[deprecated(
141 since = "0.2.2",
142 note = "use `into_page_response`: a `PageResponse` writes these headers itself and \
143 fails rather than dropping an invalid policy. Removed in 3.0.0"
144 )]
145 pub fn headers(&self) -> Vec<(axum::http::HeaderName, axum::http::HeaderValue)> {
146 let mut headers = vec![
147 (
148 axum::http::header::CONTENT_TYPE,
149 axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
150 ),
151 (
152 axum::http::header::CACHE_CONTROL,
153 axum::http::HeaderValue::from_static("no-store"),
154 ),
155 ];
156 if let Ok(value) = axum::http::HeaderValue::from_str(&self.csp) {
157 headers.push((axum::http::header::CONTENT_SECURITY_POLICY, value));
158 }
159 // Trusted Types, reported and not enforced (DR-20), as a page with
160 // no reporting endpoint carries them: in development builds only, to
161 // the browser's console.
162 if let Some(Ok(value)) =
163 crate::csp::report_only_policy().map(|text| axum::http::HeaderValue::from_str(&text))
164 {
165 headers.push((
166 axum::http::header::CONTENT_SECURITY_POLICY_REPORT_ONLY,
167 value,
168 ));
169 }
170 headers.extend_from_slice(document_security_headers());
171 headers
172 }
173
174 /// This page as a [`PageResponse`](crate::PageResponse): `no-store`, the
175 /// strict policy for the page's nonce with `extensions` added, and the
176 /// document headers, written whenever it is sent.
177 ///
178 /// The policy is rebuilt from the nonce the HTML embeds, so a `csp` edited
179 /// as text would not carry over: it is refused instead, wherever the edit
180 /// went (a widening one would fail in the browser, a tightening one would
181 /// be lost). Express the edit as an extension:
182 ///
183 /// ```no_run
184 /// # #![allow(deprecated)]
185 /// # use forma_server::{render_page, PageConfig, PolicyExtensions};
186 /// # fn config() -> PageConfig<'static> { unimplemented!() }
187 /// // Was: page.csp.replace("img-src 'self' data:", "img-src 'self' data: https:")
188 /// let page = render_page(&config())
189 /// .into_page_response(&PolicyExtensions::new().img_src("https:"))?;
190 /// # Ok::<(), forma_server::PolicyError>(())
191 /// ```
192 ///
193 /// The bridge trusts the deprecated public `nonce` and `html` as they
194 /// are: a nonce the caller chose (through `render_page_with_nonce`, or by
195 /// assigning the field) carries into the `PageResponse`. A fresh nonce
196 /// per response is guaranteed only when the render draws it, as
197 /// `render_page` and `try_render` do.
198 ///
199 /// # Errors
200 ///
201 /// A [`PolicyError`](crate::PolicyError) naming the first extension that
202 /// is not a source a page may add, or, for directive
203 /// `content-security-policy`, a `csp` that is not the one forma-server
204 /// wrote for this page's nonce. There is then no response to send:
205 /// answer with an error page rather than this one without its policy.
206 ///
207 /// Verified by: crates/forma-server/tests/page_response.rs > "into_page_response_refuses_a_policy_edited_as_text"
208 pub fn into_page_response(
209 self,
210 extensions: &crate::csp::PolicyExtensions,
211 ) -> Result<crate::PageResponse, crate::PolicyError> {
212 if self.csp != crate::csp::build_csp_header(&self.nonce) {
213 return Err(crate::csp::PolicyError::edited_as_text(self.csp));
214 }
215 crate::PageResponse::new(self.html, self.nonce, extensions, self.counts)
216 }
217}
218
219/// `X-Content-Type-Options: nosniff` — belongs on every response, not just
220/// documents.
221pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
222
223/// Security headers that only make sense on an HTML **document** response.
224///
225/// Deliberately not one flat set applied as a blanket layer: a browser ignores
226/// `Cross-Origin-Opener-Policy`, `Referrer-Policy` and `Permissions-Policy` on
227/// a JSON/CSS/JS response, so a global layer would put ~150 dead bytes on
228/// every asset and every API poll. Use [`asset_security_headers`] there.
229///
230/// HSTS is deliberately absent: it is the TLS terminator's responsibility, not
231/// the application's.
232///
233/// Caveat on `Cross-Origin-Opener-Policy: same-origin` — it applies only to
234/// top-level documents in a secure context (a no-op over plain HTTP on a
235/// non-localhost host) and it severs `window.opener`, which breaks popup-based
236/// OAuth flows.
237pub fn document_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
238 use axum::http::{HeaderName, HeaderValue};
239 static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
240 std::sync::OnceLock::new();
241 HEADERS.get_or_init(|| {
242 vec![
243 (
244 HeaderName::from_static("x-content-type-options"),
245 HeaderValue::from_static("nosniff"),
246 ),
247 (
248 HeaderName::from_static("referrer-policy"),
249 HeaderValue::from_static("strict-origin-when-cross-origin"),
250 ),
251 (
252 // `interest-cohort=()` is deliberately omitted: FLoC was
253 // withdrawn in 2022 and the token is not a registered feature,
254 // so Chrome logs an "Unrecognized feature" warning for it on
255 // every page load.
256 HeaderName::from_static("permissions-policy"),
257 HeaderValue::from_static("geolocation=(), camera=(), microphone=()"),
258 ),
259 (
260 HeaderName::from_static("cross-origin-opener-policy"),
261 HeaderValue::from_static("same-origin"),
262 ),
263 ]
264 })
265}
266
267/// Security headers for subresource responses (`/_assets/*`, `/sw.js`).
268///
269/// `Cross-Origin-Resource-Policy` belongs here rather than on the document.
270/// `same-origin` is correct while assets are served from the app's own origin;
271/// an app that fronts `/_assets` with a separate CDN origin must relax it.
272pub fn asset_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
273 use axum::http::{HeaderName, HeaderValue};
274 static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
275 std::sync::OnceLock::new();
276 HEADERS.get_or_init(|| {
277 vec![
278 (
279 HeaderName::from_static("x-content-type-options"),
280 HeaderValue::from_static("nosniff"),
281 ),
282 (
283 HeaderName::from_static("cross-origin-resource-policy"),
284 HeaderValue::from_static("same-origin"),
285 ),
286 ]
287 })
288}
289
290/// Asset manifest loaded from `manifest.json` in the embedded dist directory.
291// Its `wasm` field and that field's type are deprecated; the derives below
292// name both, and the allow keeps those generated uses quiet. A caller's own
293// use still warns.
294#[allow(deprecated)]
295#[derive(Debug, Clone, Deserialize)]
296pub struct AssetManifest {
297 /// Manifest schema version.
298 pub version: u32,
299 /// Identifier for the build that produced these assets.
300 pub build_hash: String,
301 /// Logical asset name → content-hashed filename.
302 pub assets: HashMap<String, String>,
303 /// Route pattern → the assets that route needs.
304 pub routes: HashMap<String, RouteAssets>,
305 /// The WASM walker, when the build ships one. When it is set, a page whose
306 /// route has an `.ir` module gets a nonced `window.__FORMA_WASM__` script
307 /// naming the loader, the binary and the module; that injection goes in
308 /// 3.0.0 with this field.
309 #[deprecated(
310 since = "0.2.2",
311 note = "the WASM walker is deprecated: @getforma/core/wasm, forma-ir's `wasm` feature \
312 and the build's `wasm` key go in 3.0.0, and with them this field and the \
313 `window.__FORMA_WASM__` script it adds to the page. Islands re-render with \
314 @getforma/core's `h()`. Removed in 3.0.0"
315 )]
316 #[serde(default)]
317 pub wasm: Option<WasmAssets>,
318}
319
320/// Per-route asset references.
321#[derive(Debug, Clone, Deserialize)]
322pub struct RouteAssets {
323 /// JS modules for this route, in load order.
324 pub js: Vec<String>,
325 /// Stylesheets for this route.
326 pub css: Vec<String>,
327 /// Fonts to preload for this route.
328 pub fonts: Vec<String>,
329 /// Total brotli-compressed size of the route's assets, in bytes.
330 pub total_size_br: u64,
331 /// Size above which the build warns about this route's payload.
332 pub budget_warn_threshold: u64,
333 /// The route's compiled FMIR module, as its hashed `.ir` file name, when it
334 /// has one (Phase 2).
335 #[serde(default)]
336 pub ir: Option<String>,
337}
338
339/// WASM asset references: the WASM walker's loader and binary, from the
340/// manifest's `wasm` entry.
341#[deprecated(
342 since = "0.2.2",
343 note = "the WASM walker is deprecated: @getforma/core/wasm, forma-ir's `wasm` feature \
344 and the build's `wasm` key go in 3.0.0, and with them `AssetManifest::wasm` and \
345 this type. Islands re-render with @getforma/core's `h()`. Removed in 3.0.0"
346)]
347#[derive(Debug, Clone, Deserialize)]
348pub struct WasmAssets {
349 /// The JS glue module wasm-bindgen emits.
350 pub loader: String,
351 /// The `.wasm` binary itself.
352 pub binary: String,
353}
354
355impl AssetManifest {
356 /// Resolve a logical asset name to its content-hashed filename.
357 pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
358 self.assets
359 .get(logical_name)
360 .map(|s| s.as_str())
361 .unwrap_or(logical_name)
362 }
363
364 /// Get route assets for a route pattern.
365 pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
366 self.routes.get(pattern)
367 }
368}