Skip to main content

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}