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}