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    /// Use [`PageConfig::config_json`] for anything derived from user input.
32    pub config_script: Option<&'a str>,
33    /// Optional server data to expose to the client as
34    /// `window.__FORMA_CONFIG__`.
35    ///
36    /// This is the safe path: the value is serialized by `serde_json` with
37    /// every `<` escaped, which cannot be misused no matter what the data
38    /// contains. Prefer it over [`PageConfig::config_script`].
39    pub config_json: Option<&'a serde_json::Value>,
40    /// Optional `class` attribute for `<body>`, HTML-escaped.
41    pub body_class: Option<&'a str>,
42    /// Optional inline `<style>` for personality/theme overrides.
43    ///
44    /// Emitted inside a `<style>` element. `</` is neutralized (as a CSS
45    /// escape, losslessly) so the value cannot break out; everything else is
46    /// passed through as CSS.
47    pub personality_css: Option<&'a str>,
48    /// Optional extra HTML before `<div id="app">`.
49    ///
50    /// **RAW, TRUSTED HTML.** Interpolated verbatim with no escaping — that is
51    /// the point of the field (callers use it to emit their own markup, e.g. a
52    /// hand-rendered `__forma_islands` block). Never build it from untrusted
53    /// data.
54    pub body_prefix: Option<&'a str>,
55    /// Whether to server-render the body or ship an empty `#app` shell.
56    pub render_mode: RenderMode,
57    /// Pre-parsed IR module for Phase 2 SSR
58    pub ir_module: Option<&'a forma_ir::parser::IrModule>,
59    /// Slot data for Phase 2 SSR
60    pub slots: Option<&'a forma_ir::slot::SlotData>,
61}
62
63/// Output from render_page: HTML body + CSP header value + the nonce binding
64/// them together.
65///
66/// The HTML and the CSP header are a single unit — the HTML embeds a
67/// per-request nonce that only this policy blesses. Serve them together with
68/// `Cache-Control: no-store` and never cache the HTML across requests: a
69/// cached body with a fresh policy kills every script, and caching both ships
70/// one static, publicly-known nonce to every user, which defeats nonce-based
71/// CSP entirely.
72#[non_exhaustive]
73#[derive(Debug)]
74pub struct PageOutput {
75    /// The complete HTML document.
76    pub html: String,
77    /// The `Content-Security-Policy` header value that blesses it.
78    pub csp: String,
79    /// The nonce embedded in `html` and blessed by `csp`.
80    ///
81    /// Exposed so a caller can add its own nonced `<script>`/`<style>`, or
82    /// re-stamp a cached shell, without string-parsing the CSP.
83    pub nonce: crate::csp::CspNonce,
84}
85
86impl PageOutput {
87    /// Response headers for this page: content type, `no-store`, and the CSP.
88    ///
89    /// Reads the (public, mutable) `csp` field, so a caller that must rewrite
90    /// a directive can do so in place first and still use this helper.
91    pub fn headers(&self) -> Vec<(axum::http::HeaderName, axum::http::HeaderValue)> {
92        let mut headers = vec![
93            (
94                axum::http::header::CONTENT_TYPE,
95                axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
96            ),
97            (
98                axum::http::header::CACHE_CONTROL,
99                axum::http::HeaderValue::from_static("no-store"),
100            ),
101        ];
102        if let Ok(value) = axum::http::HeaderValue::from_str(&self.csp) {
103            headers.push((axum::http::header::CONTENT_SECURITY_POLICY, value));
104        }
105        headers.extend_from_slice(document_security_headers());
106        headers
107    }
108}
109
110/// `X-Content-Type-Options: nosniff` — belongs on every response, not just
111/// documents.
112pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
113
114/// Security headers that only make sense on an HTML **document** response.
115///
116/// Deliberately not one flat set applied as a blanket layer: a browser ignores
117/// `Cross-Origin-Opener-Policy`, `Referrer-Policy` and `Permissions-Policy` on
118/// a JSON/CSS/JS response, so a global layer would put ~150 dead bytes on
119/// every asset and every API poll. Use [`asset_security_headers`] there.
120///
121/// HSTS is deliberately absent: it is the TLS terminator's responsibility, not
122/// the application's.
123///
124/// Caveat on `Cross-Origin-Opener-Policy: same-origin` — it applies only to
125/// top-level documents in a secure context (a no-op over plain HTTP on a
126/// non-localhost host) and it severs `window.opener`, which breaks popup-based
127/// OAuth flows.
128pub fn document_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
129    use axum::http::{HeaderName, HeaderValue};
130    static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
131        std::sync::OnceLock::new();
132    HEADERS.get_or_init(|| {
133        vec![
134            (
135                HeaderName::from_static("x-content-type-options"),
136                HeaderValue::from_static("nosniff"),
137            ),
138            (
139                HeaderName::from_static("referrer-policy"),
140                HeaderValue::from_static("strict-origin-when-cross-origin"),
141            ),
142            (
143                // `interest-cohort=()` is deliberately omitted: FLoC was
144                // withdrawn in 2022 and the token is not a registered feature,
145                // so Chrome logs an "Unrecognized feature" warning for it on
146                // every page load.
147                HeaderName::from_static("permissions-policy"),
148                HeaderValue::from_static("geolocation=(), camera=(), microphone=()"),
149            ),
150            (
151                HeaderName::from_static("cross-origin-opener-policy"),
152                HeaderValue::from_static("same-origin"),
153            ),
154        ]
155    })
156}
157
158/// Security headers for subresource responses (`/_assets/*`, `/sw.js`).
159///
160/// `Cross-Origin-Resource-Policy` belongs here rather than on the document.
161/// `same-origin` is correct while assets are served from the app's own origin;
162/// an app that fronts `/_assets` with a separate CDN origin must relax it.
163pub fn asset_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
164    use axum::http::{HeaderName, HeaderValue};
165    static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
166        std::sync::OnceLock::new();
167    HEADERS.get_or_init(|| {
168        vec![
169            (
170                HeaderName::from_static("x-content-type-options"),
171                HeaderValue::from_static("nosniff"),
172            ),
173            (
174                HeaderName::from_static("cross-origin-resource-policy"),
175                HeaderValue::from_static("same-origin"),
176            ),
177        ]
178    })
179}
180
181/// Asset manifest loaded from `manifest.json` in the embedded dist directory.
182#[derive(Debug, Clone, Deserialize)]
183pub struct AssetManifest {
184    /// Manifest schema version.
185    pub version: u32,
186    /// Identifier for the build that produced these assets.
187    pub build_hash: String,
188    /// Logical asset name → content-hashed filename.
189    pub assets: HashMap<String, String>,
190    /// Route pattern → the assets that route needs.
191    pub routes: HashMap<String, RouteAssets>,
192    /// The WASM walker, when the build ships one.
193    #[serde(default)]
194    pub wasm: Option<WasmAssets>,
195}
196
197/// Per-route asset references.
198#[derive(Debug, Clone, Deserialize)]
199pub struct RouteAssets {
200    /// JS modules for this route, in load order.
201    pub js: Vec<String>,
202    /// Stylesheets for this route.
203    pub css: Vec<String>,
204    /// Fonts to preload for this route.
205    pub fonts: Vec<String>,
206    /// Total brotli-compressed size of the route's assets, in bytes.
207    pub total_size_br: u64,
208    /// Size above which the build warns about this route's payload.
209    pub budget_warn_threshold: u64,
210    /// The route's compiled `.fmir` module, when it has one (Phase 2).
211    #[serde(default)]
212    pub ir: Option<String>,
213}
214
215/// WASM asset references.
216#[derive(Debug, Clone, Deserialize)]
217pub struct WasmAssets {
218    /// The JS glue module wasm-bindgen emits.
219    pub loader: String,
220    /// The `.wasm` binary itself.
221    pub binary: String,
222}
223
224impl AssetManifest {
225    /// Resolve a logical asset name to its content-hashed filename.
226    pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
227        self.assets
228            .get(logical_name)
229            .map(|s| s.as_str())
230            .unwrap_or(logical_name)
231    }
232
233    /// Get route assets for a route pattern.
234    pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
235        self.routes.get(pattern)
236    }
237}