Skip to main content

ocserve_http/
embedded_ui.rs

1//! Embedded, version-matched web UI (WEBUI-PLAN P6).
2//!
3//! Upstream's `serveUIEffect` is **embedded-first**: the release binary embeds
4//! the whole `packages/app/dist` and serves it at `/` + `/assets/*`, proxying
5//! to `app.opencode.ai` only when embedding is disabled. ocserve historically
6//! had only the proxy — so it served a *newer* Cloudflare UI against the
7//! frozen server (version skew; the exact bug class the user hit). This module
8//! restores upstream's priority: serve the pinned 1.18.31 build first, fall
9//! back to the proxy only under `OCSERVE_DISABLE_EMBEDDED_WEB_UI=1` (mirroring
10//! upstream's `OPENCODE_DISABLE_EMBEDDED_WEB_UI`).
11//!
12//! The pinned build is a single zstd pack (`assets/webui/<tag>.pack.zst`,
13//! produced by `scripts/extract-app.sh` through the freeze's PUBLIC HTTP
14//! interface — not binary scraping). Pack format before zstd: `OCAP1\n` +
15//! u32 count + (u16 pathlen, path, u64 datalen, data)*. Decompressed once at
16//! boot into an in-memory map (bounded: the closure is ~10 MB raw).
17//!
18//! Air-gapped property: an unknown asset under the embedded arm returns the
19//! upstream JSON 404 `{"error":"Not Found"}` — it never falls through to the
20//! network. That is exactly upstream's embedded behavior.
21
22use std::collections::HashMap;
23use std::sync::OnceLock;
24
25/// The pinned pack. `include_bytes!` keeps `/` working on a bare machine with
26/// no network and no data dir (the "boots with defaults" invariant).
27///
28/// Lives INSIDE this crate (`assets/webui/<tag>.pack.zst`) so it is packaged
29/// and published with the crate; embedding from `bench/` (outside the crate
30/// root) is not in the `.crate` tarball, so `cargo install ocserve` fails to
31/// compile it (the 0.3.0 defect; guard rule 30). Produced by
32/// `scripts/extract-app.sh` through the freeze's public HTTP interface.
33static PACK_ZST: &[u8] = include_bytes!("../assets/webui/1.18.31.pack.zst");
34
35/// The freeze tag the pack was built from — must match `FREEZE_VERSION`.
36pub const APP_TAG: &str = "1.18.31";
37
38static FILES: OnceLock<HashMap<String, Vec<u8>>> = OnceLock::new();
39
40/// Decompress + parse the pack once. Returns the path→bytes map (empty on a
41/// malformed pack — never panics, so a corrupt artifact degrades to the proxy
42/// rather than killing boot).
43fn files() -> &'static HashMap<String, Vec<u8>> {
44    FILES.get_or_init(|| {
45        let raw = match zstd::stream::decode_all(PACK_ZST) {
46            Ok(r) => r,
47            Err(e) => {
48                tracing::warn!("embedded UI pack: zstd decode failed: {e:#}");
49                return HashMap::new();
50            }
51        };
52        parse_pack(&raw).unwrap_or_else(|e| {
53            tracing::warn!("embedded UI pack: parse failed: {e}");
54            HashMap::new()
55        })
56    })
57}
58
59fn parse_pack(raw: &[u8]) -> Result<HashMap<String, Vec<u8>>, String> {
60    if raw.len() < 6 || &raw[..6] != b"OCAP1\n" {
61        return Err("bad magic".into());
62    }
63    let mut i = 6usize;
64    let count = read_u32(raw, &mut i)? as usize;
65    let mut map = HashMap::with_capacity(count);
66    for _ in 0..count {
67        let plen = read_u16(raw, &mut i)? as usize;
68        if i + plen > raw.len() {
69            return Err("path overruns".into());
70        }
71        let path = String::from_utf8(raw[i..i + plen].to_vec()).map_err(|_| "bad path utf8")?;
72        i += plen;
73        let dlen = read_u64(raw, &mut i)? as usize;
74        if i + dlen > raw.len() {
75            return Err("data overruns".into());
76        }
77        let data = raw[i..i + dlen].to_vec();
78        i += dlen;
79        map.insert(path, data);
80    }
81    Ok(map)
82}
83
84fn read_u16(b: &[u8], i: &mut usize) -> Result<u16, String> {
85    if *i + 2 > b.len() {
86        return Err("eof u16".into());
87    }
88    let v = u16::from_le_bytes([b[*i], b[*i + 1]]);
89    *i += 2;
90    Ok(v)
91}
92fn read_u32(b: &[u8], i: &mut usize) -> Result<u32, String> {
93    if *i + 4 > b.len() {
94        return Err("eof u32".into());
95    }
96    let v = u32::from_le_bytes([b[*i], b[*i + 1], b[*i + 2], b[*i + 3]]);
97    *i += 4;
98    Ok(v)
99}
100fn read_u64(b: &[u8], i: &mut usize) -> Result<u64, String> {
101    if *i + 8 > b.len() {
102        return Err("eof u64".into());
103    }
104    let v = u64::from_le_bytes(b[*i..*i + 8].try_into().map_err(|_| "u64")?);
105    *i += 8;
106    Ok(v)
107}
108
109/// True when the embedded pack is present and parsed (non-empty).
110pub fn available() -> bool {
111    !files().is_empty()
112}
113
114/// Look up a request path in the embedded build. Mirrors upstream
115/// `serveEmbeddedUIEffect`: `/` and unknown non-asset paths fall back to
116/// `index.html` (SPA routing); `/assets/*` is exact (an unknown asset is a
117/// 404, never `index.html`). Returns `(effective_path, bytes)` so the caller
118/// uses the resolved file's MIME (an SPA-fallback `/route` is text/html).
119pub fn lookup(path: &str) -> Option<(&'static str, &'static [u8])> {
120    let map = files();
121    let key = path.trim_start_matches('/');
122    if let Some((k, data)) = map.get_key_value(key) {
123        // k borrows the 'static map, so it outlives the borrow of `path`.
124        return Some((k.as_str(), data));
125    }
126    // SPA fallback: only for non-asset paths (assets must 404, else a missing
127    // font would be served HTML and the browser would choke).
128    if !path.starts_with("/assets/")
129        && let Some(data) = map.get("index.html")
130    {
131        return Some(("index.html", data));
132    }
133    None
134}
135
136/// MIME type by extension (small table; matches what freeze serves).
137pub fn mime_for(path: &str) -> &'static str {
138    let p = path.split('?').next().unwrap_or(path);
139    match p.rsplit('.').next().unwrap_or("") {
140        "html" => "text/html",
141        "js" | "mjs" => "text/javascript",
142        "css" => "text/css",
143        "json" | "webmanifest" => "application/json",
144        "svg" => "image/svg+xml",
145        "png" => "image/png",
146        "ico" => "image/x-icon",
147        "ttf" => "font/ttf",
148        "woff" => "font/woff",
149        "woff2" => "font/woff2",
150        "mp4" => "video/mp4",
151        "wasm" => "application/wasm",
152        "txt" => "text/plain",
153        _ => "application/octet-stream",
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn pack_parses_and_has_entrypoints() {
163        // The committed pack must parse and carry the SPA entry + its JS.
164        assert!(available(), "embedded pack did not parse");
165        let map = files();
166        assert!(map.contains_key("index.html"), "no index.html");
167        // entry JS is content-hashed; find one
168        assert!(
169            map.keys()
170                .any(|k| k.starts_with("assets/index-") && k.ends_with(".js")),
171            "no entry JS"
172        );
173    }
174
175    #[test]
176    fn spa_fallback_only_for_non_assets() {
177        // unknown non-asset → index.html (resolved path is the html entry)
178        let (resolved, idx) = lookup("/some/spa/route").expect("spa fallback");
179        assert_eq!(resolved, "index.html");
180        assert!(String::from_utf8_lossy(idx).contains("<"));
181        // unknown asset → None (must 404, never HTML)
182        assert!(lookup("/assets/does-not-exist-xyz.js").is_none());
183    }
184
185    #[test]
186    fn mime_table() {
187        assert_eq!(mime_for("/assets/index-abc.js"), "text/javascript");
188        assert_eq!(mime_for("/assets/index-abc.css"), "text/css");
189        assert_eq!(mime_for("/assets/font.woff2"), "font/woff2");
190        assert_eq!(mime_for("/"), "application/octet-stream");
191        assert_eq!(mime_for("/index.html"), "text/html");
192    }
193}