Skip to main content

wire/
assets.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The client the browser downloads, and which bytes answer which path.
7//!
8//! Two sources, one contract: the release binary carries the bundle
9//! inside itself, and a development loop points at the directory the
10//! wasm build writes, read per request so an edit shows on refresh.
11//! Which source is in use changes no policy, which is what makes this
12//! an adapter rather than a decision.
13//!
14//! The whole of the path policy is here and none of it is in a handler:
15//! `""` and `"/"` mean the index page, a path that steps outside the
16//! bundle is a miss rather than an error, and the content type is the
17//! one a browser needs to run a WebAssembly client. `wire::server`
18//! adds headers to what this answers and decides nothing.
19
20use kernel::{AxCode, AxError};
21
22/// One file of the embedded client: the path a browser requests and the
23/// gzip bytes the build wrote into the binary.
24#[derive(Debug, Clone, Copy)]
25pub struct EmbeddedFile {
26    /// Request path relative to the root, forward slashes, no leading
27    /// slash: `index.html`, `web.js`, `snippets/<crate>/inline0.js`.
28    pub path: &'static str,
29    /// The file, gzip-compressed at build time with a zeroed timestamp,
30    /// so the same source bytes embed identically on every build.
31    pub gz: &'static [u8],
32}
33
34/// The client the browser downloads, from whichever source this process
35/// was given. Two sources, one contract: the release binary carries the
36/// bundle inside itself; a development loop points at the directory the
37/// wasm build writes, read per request so an edit shows on refresh.
38#[derive(Debug)]
39pub enum ClientAssets {
40    /// The bundle inside the binary, one gzip per file.
41    Embedded(&'static [EmbeddedFile]),
42    /// A directory on disk, read per request. Loopback development only;
43    /// the release path never constructs this arm.
44    Disk(std::path::PathBuf),
45}
46
47/// What one asset request gets back.
48#[derive(Debug)]
49pub enum AssetReply {
50    Found {
51        bytes: Vec<u8>,
52        content_type: &'static str,
53        /// Whether `bytes` are gzip and need `Content-Encoding: gzip`.
54        gzipped: bool,
55    },
56    Miss(AxError),
57}
58
59impl ClientAssets {
60    /// Answers one request path. Pure over the embedded arm; the disk arm
61    /// reads exactly the file the sanitised path names.
62    ///
63    /// `""` and `"/"` mean `index.html`. A path that steps outside the
64    /// bundle (`..`, empty segments, drive letters, leading dots) is a
65    /// miss, not an error: the bundle is the whole world this route knows.
66    #[must_use]
67    pub fn lookup(&self, request_path: &str) -> AssetReply {
68        let Some(rel) = sanitize_asset_path(request_path) else {
69            return AssetReply::Miss(
70                AxError::failure(
71                    AxCode::InvalidArgs,
72                    "serve a client asset",
73                    format!("the path {request_path} steps outside the client bundle"),
74                )
75                .with_recovery("request a bundle-relative path such as /web.js"),
76            );
77        };
78        match self {
79            ClientAssets::Embedded(files) => {
80                for file in *files {
81                    if file.path == rel {
82                        return AssetReply::Found {
83                            bytes: file.gz.to_vec(),
84                            content_type: content_type_of(&rel),
85                            gzipped: true,
86                        };
87                    }
88                }
89                AssetReply::Miss(missing_asset(&rel, "this binary"))
90            }
91            ClientAssets::Disk(root) => {
92                let full = root.join(&rel);
93                match std::fs::read(&full) {
94                    Ok(bytes) => AssetReply::Found {
95                        bytes,
96                        content_type: content_type_of(&rel),
97                        gzipped: false,
98                    },
99                    Err(_) => AssetReply::Miss(missing_asset(&rel, "the --web-dir directory")),
100                }
101            }
102        }
103    }
104}
105
106fn missing_asset(rel: &str, source: &str) -> AxError {
107    AxError::failure(
108        AxCode::InvalidArgs,
109        "serve a client asset",
110        format!("{source} does not carry {rel}"),
111    )
112    .with_recovery("rebuild the client (`just build-web`), then rebuild or restart the server")
113}
114
115/// Normalises a request path to a bundle-relative one, or refuses.
116/// Rejects `..`, empty segments, backslashes, drive colons and segments
117/// that start with a dot; empty input means the index page.
118fn sanitize_asset_path(raw: &str) -> Option<String> {
119    let trimmed = raw.trim_start_matches('/');
120    if trimmed.is_empty() {
121        return Some("index.html".to_owned());
122    }
123    if trimmed.contains('\\') || trimmed.contains(':') {
124        return None;
125    }
126    let mut segments = Vec::new();
127    for segment in trimmed.split('/') {
128        if segment.is_empty() || segment.starts_with('.') {
129            return None;
130        }
131        segments.push(segment);
132    }
133    Some(segments.join("/"))
134}
135
136/// The content type a browser needs to run the client. `application/wasm`
137/// is load-bearing: `WebAssembly.instantiateStreaming` refuses anything
138/// else and the loader falls back to a slower path with a console warning.
139fn content_type_of(rel: &str) -> &'static str {
140    let suffix = rel.rsplit('.').next().unwrap_or_default();
141    match suffix {
142        "html" => "text/html; charset=utf-8",
143        "js" | "mjs" => "text/javascript; charset=utf-8",
144        "wasm" => "application/wasm",
145        "css" => "text/css; charset=utf-8",
146        "json" | "map" => "application/json",
147        "svg" => "image/svg+xml",
148        _ => "application/octet-stream",
149    }
150}
151#[cfg(test)]
152#[allow(
153    clippy::unwrap_used,
154    clippy::expect_used,
155    clippy::panic,
156    clippy::indexing_slicing,
157    reason = "test code"
158)]
159mod tests {
160    use super::*;
161
162    const BUNDLE: &[EmbeddedFile] = &[
163        EmbeddedFile {
164            path: "index.html",
165            gz: b"gzipped-html",
166        },
167        EmbeddedFile {
168            path: "web.js",
169            gz: b"gzipped-js",
170        },
171        EmbeddedFile {
172            path: "web_bg.wasm",
173            gz: b"gzipped-wasm",
174        },
175        EmbeddedFile {
176            path: "snippets/dioxus-abc/inline0.js",
177            gz: b"gzipped-snippet",
178        },
179    ];
180
181    #[test]
182    fn the_embedded_bundle_answers_the_paths_the_page_asks_for() {
183        let assets = ClientAssets::Embedded(BUNDLE);
184        for (asked, want_type, want_bytes) in [
185            ("", "text/html; charset=utf-8", b"gzipped-html".as_slice()),
186            ("/", "text/html; charset=utf-8", b"gzipped-html".as_slice()),
187            (
188                "web.js",
189                "text/javascript; charset=utf-8",
190                b"gzipped-js".as_slice(),
191            ),
192            (
193                "/web_bg.wasm",
194                "application/wasm",
195                b"gzipped-wasm".as_slice(),
196            ),
197            (
198                "snippets/dioxus-abc/inline0.js",
199                "text/javascript; charset=utf-8",
200                b"gzipped-snippet".as_slice(),
201            ),
202        ] {
203            let AssetReply::Found {
204                bytes,
205                content_type,
206                gzipped,
207            } = assets.lookup(asked)
208            else {
209                panic!("{asked} must be found");
210            };
211            assert_eq!(bytes, want_bytes, "{asked}");
212            assert_eq!(content_type, want_type, "{asked}");
213            assert!(gzipped, "embedded files travel gzipped");
214        }
215    }
216
217    #[test]
218    fn a_path_outside_the_bundle_is_a_miss_that_names_the_path() {
219        let assets = ClientAssets::Embedded(BUNDLE);
220        for hostile in [
221            "../Cargo.toml",
222            "a/../../secret",
223            "a//b.js",
224            "C:/windows/system32",
225            "snippets\\x\\y.js",
226            ".git/config",
227        ] {
228            let AssetReply::Miss(err) = assets.lookup(hostile) else {
229                panic!("{hostile} must miss");
230            };
231            assert!(!err.recovery().is_empty(), "a miss says what to do next");
232        }
233    }
234
235    #[test]
236    fn an_unknown_file_misses_and_the_recovery_names_the_rebuild() {
237        let assets = ClientAssets::Embedded(BUNDLE);
238        let AssetReply::Miss(err) = assets.lookup("missing.js") else {
239            panic!("an absent file is a miss");
240        };
241        assert!(err.recovery().contains("just build-web"));
242    }
243
244    #[test]
245    fn the_disk_arm_reads_per_request_and_never_claims_gzip() {
246        let dir = std::env::temp_dir().join(format!("sprawl-assets-{}", std::process::id()));
247        std::fs::create_dir_all(&dir).unwrap();
248        std::fs::write(dir.join("web.js"), b"fresh from disk").unwrap();
249        let assets = ClientAssets::Disk(dir.clone());
250        let AssetReply::Found {
251            bytes,
252            content_type,
253            gzipped,
254        } = assets.lookup("/web.js")
255        else {
256            panic!("the file exists");
257        };
258        assert_eq!(bytes, b"fresh from disk");
259        assert_eq!(content_type, "text/javascript; charset=utf-8");
260        assert!(!gzipped, "disk bytes are identity-encoded");
261        std::fs::write(dir.join("web.js"), b"edited").unwrap();
262        let AssetReply::Found { bytes, .. } = assets.lookup("web.js") else {
263            panic!("still there");
264        };
265        assert_eq!(
266            bytes, b"edited",
267            "a refresh sees the edit without a restart"
268        );
269        std::fs::remove_dir_all(&dir).ok();
270    }
271}