Skip to main content

forma_server/
assets.rs

1use axum::{
2    body::{Body, Bytes},
3    extract::Path,
4    http::{header, HeaderMap, StatusCode},
5    response::{IntoResponse, Response},
6};
7use rust_embed::Embed;
8
9/// Load a text asset from the embedded dist directory. Panics if missing.
10///
11/// STARTUP ONLY. This panics on a missing or non-UTF8 file, so it must never
12/// be reached from a request path — use [`try_asset`] there.
13pub fn asset<A: Embed>(name: &str) -> String {
14    try_asset::<A>(name).unwrap_or_else(|e| panic!("{e}"))
15}
16
17/// Fallible [`asset`] — use this anywhere a request can influence `name`.
18pub fn try_asset<A: Embed>(name: &str) -> Result<String, String> {
19    let file = A::get(name).ok_or_else(|| format!("Missing asset: {name}"))?;
20    std::str::from_utf8(&file.data)
21        .map(|s| s.to_owned())
22        .map_err(|_| format!("Non-UTF8 asset: {name}"))
23}
24
25/// Borrow an embedded file's bytes without copying them.
26///
27/// In release builds `rust-embed` hands back `Cow::Borrowed(&'static [u8])`,
28/// so this is a pointer copy. The previous `.to_vec()` meant N concurrent
29/// requests for the biggest asset (the WASM binary) allocated N full copies —
30/// an unauthenticated memory amplifier that scales with connection count.
31fn bytes_of(file: rust_embed::EmbeddedFile) -> Bytes {
32    match file.data {
33        std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
34        std::borrow::Cow::Owned(v) => Bytes::from(v),
35    }
36}
37
38fn body_of(file: rust_embed::EmbeddedFile) -> Body {
39    Body::from(bytes_of(file))
40}
41
42/// Load raw bytes from the embedded dist directory. Returns None if missing.
43///
44/// Returns [`Bytes`] rather than `Vec<u8>` so callers do not copy the file;
45/// `Bytes` derefs to `[u8]` and converts into a response `Body` for free.
46pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
47    A::get(name).map(bytes_of)
48}
49
50/// Load and parse the asset manifest from embedded assets.
51///
52/// STARTUP ONLY — panics if the manifest is missing or malformed.
53pub fn load_manifest<A: Embed>() -> crate::AssetManifest {
54    try_load_manifest::<A>().unwrap_or_else(|e| panic!("{e}"))
55}
56
57/// Fallible [`load_manifest`].
58pub fn try_load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
59    let data = try_asset::<A>("manifest.json")?;
60    serde_json::from_str(&data).map_err(|e| format!("Failed to parse manifest.json: {e}"))
61}
62
63/// Files that must never be served through the asset route.
64///
65/// `manifest.json` exposes the full route table, asset graph and per-route
66/// size budgets, and nothing client-side fetches it. A direct `.br`/`.gz`
67/// request would return compressed bytes with no `Content-Encoding`, i.e. a
68/// corrupt response — those siblings are only ever reachable through content
69/// negotiation below.
70fn is_denied(filename: &str) -> bool {
71    filename == "manifest.json" || filename.ends_with(".br") || filename.ends_with(".gz")
72}
73
74/// A parsed `Accept-Encoding` entry.
75struct Encoding<'a> {
76    token: &'a str,
77    q: f32,
78}
79
80/// Parse `Accept-Encoding` into (token, qvalue) pairs.
81///
82/// RFC 9110 §12.5.3: `q=0` means "not acceptable". A substring test reads
83/// `br;q=0` — the standard way a client or proxy says it cannot decode
84/// brotli — as acceptance, and then serves an undecodable body. A substring
85/// test also matches `x-brotli-nope` as "br".
86fn parse_accept_encoding(header: &str) -> Vec<Encoding<'_>> {
87    header
88        .split(',')
89        .filter_map(|element| {
90            let mut parts = element.split(';').map(str::trim);
91            let token = parts.next()?;
92            if token.is_empty() {
93                return None;
94            }
95            // Default q=1.0; an unparseable q is lenient (also 1.0).
96            let mut q = 1.0_f32;
97            for param in parts {
98                let Some((name, value)) = param.split_once('=') else {
99                    continue;
100                };
101                // Match the parameter name exactly after trimming — splitting
102                // on the literal ";q=" would miss `br; q=0` and `br;Q=0`.
103                if name.trim() == "q" {
104                    q = value.trim().parse().unwrap_or(1.0);
105                }
106            }
107            Some(Encoding {
108                token,
109                q: q.clamp(0.0, 1.0),
110            })
111        })
112        .collect()
113}
114
115/// Axum handler: serve a static asset with content negotiation (brotli, gzip).
116///
117/// Mount at `/_assets/{filename}` in your router.
118pub async fn serve_asset<A: Embed>(Path(filename): Path<String>, headers: HeaderMap) -> Response {
119    if is_denied(&filename) {
120        return StatusCode::NOT_FOUND.into_response();
121    }
122
123    // Validate the requested name against the embedded file list BEFORE any
124    // lookup. In debug builds rust-embed reads from the filesystem, so on
125    // Windows `A::get` is case-insensitive and accepts NTFS stream syntax
126    // (`app.abc.js::$DATA`) — both of which 404 in release. Comparing against
127    // `iter()` (which yields the on-disk casing in debug and the compiled-in
128    // list in release) makes the two profiles agree, and closes the alias
129    // without a charset allowlist that would 404 legitimate un-hashed font
130    // filenames containing spaces.
131    if !is_embedded::<A>(&filename) {
132        return StatusCode::NOT_FOUND.into_response();
133    }
134
135    let accept = headers
136        .get(header::ACCEPT_ENCODING)
137        .and_then(|v| v.to_str().ok())
138        .unwrap_or("")
139        .to_ascii_lowercase();
140    let accepted = parse_accept_encoding(&accept);
141    let q_of = |name: &str| {
142        accepted
143            .iter()
144            .find(|e| e.token == name)
145            .map(|e| e.q)
146            .unwrap_or(0.0)
147    };
148
149    // Ordered preference list. `*` and `identity` deliberately fall through to
150    // the raw asset: treating `*` as licence to send brotli would be a
151    // robustness regression.
152    let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
153    // Stable sort by q descending, br winning ties (preserving the previous
154    // br-first bias).
155    candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
156
157    let if_none_match = headers
158        .get(header::IF_NONE_MATCH)
159        .and_then(|v| v.to_str().ok());
160
161    for (encoding, q) in candidates {
162        if q <= 0.0 {
163            continue;
164        }
165        let suffix = if encoding == "br" { "br" } else { "gz" };
166        // Serve the first candidate whose compressed sibling actually exists —
167        // an asset with only a .gz used to be served raw to a br+gzip client.
168        if let Some(file) = A::get(&format!("{filename}.{suffix}")) {
169            return asset_response(&filename, file, Some(encoding), if_none_match);
170        }
171    }
172
173    match A::get(&filename) {
174        Some(file) => asset_response(&filename, file, None, if_none_match),
175        None => StatusCode::NOT_FOUND.into_response(),
176    }
177}
178
179/// True if `name` is exactly an embedded file name.
180fn is_embedded<A: Embed>(name: &str) -> bool {
181    #[cfg(not(debug_assertions))]
182    {
183        // Release: the list is compiled in and immutable, so cache it.
184        use std::collections::HashSet;
185        use std::sync::OnceLock;
186        static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
187        return NAMES
188            .get_or_init(|| A::iter().map(|f| f.to_string()).collect())
189            .contains(name);
190    }
191    #[cfg(debug_assertions)]
192    {
193        // Debug: rust-embed walks the folder, so never cache — a newly built
194        // asset must be servable without restarting the dev server.
195        A::iter().any(|f| f == name)
196    }
197}
198
199/// True if the filename carries a content hash, i.e. its bytes can never
200/// change under this name.
201///
202/// `@getforma/build` composes hashed names as `<base>.<8 lowercase hex>.<ext>`.
203/// It hashes only `.js/.css/.wasm/.ir` — fonts, svg and the like keep their
204/// original names and must NOT be pinned for a year with `immutable`, or a
205/// redeploy cannot invalidate them.
206fn is_content_hashed(filename: &str) -> bool {
207    let name = filename.rsplit('/').next().unwrap_or(filename);
208    name.split('.').skip(1).any(|segment| {
209        segment.len() == 8
210            && segment
211                .bytes()
212                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
213    })
214}
215
216fn asset_response(
217    filename: &str,
218    file: rust_embed::EmbeddedFile,
219    encoding: Option<&str>,
220    if_none_match: Option<&str>,
221) -> Response {
222    let mime = mime_for(filename);
223    // Strong ETag from the content hash rust-embed already computed. This
224    // matters because un-hashed assets (every .woff2, every .svg) now get
225    // `max-age=0, must-revalidate`; without a validator that would mean a full
226    // 200-with-body re-download on every navigation.
227    let etag = format!(
228        "\"{}{}\"",
229        hex8(&file.metadata.sha256_hash()),
230        // The compressed variants are different bytes under the same URL, and
231        // `Vary: accept-encoding` is advisory to caches only, so qualify it.
232        encoding.unwrap_or("")
233    );
234
235    let cache_control = if is_content_hashed(filename) {
236        "public, max-age=31536000, immutable"
237    } else {
238        "public, max-age=0, must-revalidate"
239    };
240
241    // Answer a conditional request without re-sending the body.
242    if if_none_match.map(|v| v.split(',').any(|c| c.trim() == etag)) == Some(true) {
243        return Response::builder()
244            .status(StatusCode::NOT_MODIFIED)
245            .header(header::CACHE_CONTROL, cache_control)
246            .header(header::ETAG, &etag)
247            .header("vary", "accept-encoding")
248            .body(Body::empty())
249            .unwrap()
250            .into_response();
251    }
252
253    let mut builder = Response::builder()
254        .header(header::CONTENT_TYPE, mime)
255        .header(header::CACHE_CONTROL, cache_control)
256        .header(header::ETAG, &etag)
257        .header("vary", "accept-encoding")
258        // Harmless for js/css/wasm — a subresource's own CSP does not govern
259        // its execution, only a directly-navigated document's. It bites
260        // exactly the case it is meant to: an `.svg` or `.html` in dist that
261        // someone navigates to directly cannot run script. It does not collide
262        // with the per-page policy, which the app sets on page responses.
263        .header(
264            header::CONTENT_SECURITY_POLICY,
265            "default-src 'none'; sandbox",
266        );
267
268    for (name, value) in crate::types::asset_security_headers() {
269        builder = builder.header(name, value);
270    }
271
272    if let Some(enc) = encoding {
273        builder = builder.header(header::CONTENT_ENCODING, enc);
274    }
275
276    builder.body(body_of(file)).unwrap().into_response()
277}
278
279/// First 8 bytes of a hash, hex-encoded.
280fn hex8(hash: &[u8; 32]) -> String {
281    hash[..8].iter().map(|b| format!("{b:02x}")).collect()
282}
283
284/// Map a filename to its `Content-Type`.
285///
286/// Extension matching is case-insensitive and anchored to the last dot after
287/// the last path separator — the dashboard template mounts a wildcard route,
288/// so `filename` can contain `/`, and `img.v2/logo` must not be read as
289/// extension `v2/logo`.
290///
291/// Anything unrecognized stays `application/octet-stream`. Combined with the
292/// `nosniff` header that is the correct fail-closed answer, and the default is
293/// deliberate: `.ir` files are opaque binaries read via `arrayBuffer()`, so
294/// octet-stream is the honest type for them.
295fn mime_for(filename: &str) -> &'static str {
296    let base = filename.rsplit('/').next().unwrap_or(filename);
297    let ext = base.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
298    match ext.as_deref() {
299        // One spelling of the JS MIME for both extensions (WHATWG-preferred).
300        Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
301        Some("css") => "text/css; charset=utf-8",
302        // Hard requirement, not a nicety: the wasm-pack loader uses
303        // WebAssembly.instantiateStreaming, which strictly rejects any other
304        // type.
305        Some("wasm") => "application/wasm",
306        Some("json") => "application/json",
307        Some("map") => "application/json",
308        Some("html") => "text/html; charset=utf-8",
309        Some("txt") => "text/plain; charset=utf-8",
310        Some("xml") => "application/xml",
311        Some("webmanifest") => "application/manifest+json",
312        Some("svg") => "image/svg+xml",
313        Some("png") => "image/png",
314        Some("jpg") | Some("jpeg") => "image/jpeg",
315        Some("gif") => "image/gif",
316        Some("webp") => "image/webp",
317        Some("avif") => "image/avif",
318        Some("ico") => "image/x-icon",
319        Some("woff2") => "font/woff2",
320        Some("woff") => "font/woff",
321        Some("ttf") => "font/ttf",
322        Some("otf") => "font/otf",
323        _ => "application/octet-stream",
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn mime_matching_is_case_insensitive() {
333        // The regression that actually matters: `nosniff` hard-blocks a script
334        // or stylesheet served as octet-stream, and in debug builds the
335        // filesystem lookup is case-insensitive on Windows, so an uppercase
336        // request used to resolve the file and then be refused by the browser.
337        assert_eq!(
338            mime_for("app.abc123ef.js"),
339            "text/javascript; charset=utf-8"
340        );
341        assert_eq!(
342            mime_for("APP.ABC123EF.JS"),
343            "text/javascript; charset=utf-8"
344        );
345        assert_eq!(mime_for("app.MJS"), "text/javascript; charset=utf-8");
346        assert_eq!(mime_for("style.CSS"), "text/css; charset=utf-8");
347    }
348
349    #[test]
350    fn mime_reads_the_extension_after_the_last_separator() {
351        // A wildcard asset route can deliver a path, and `img.v2/logo` has no
352        // extension at all.
353        assert_eq!(mime_for("img.v2/logo"), "application/octet-stream");
354        assert_eq!(mime_for("img.v2/logo.png"), "image/png");
355        assert_eq!(mime_for("noextension"), "application/octet-stream");
356    }
357
358    #[test]
359    fn mime_covers_images_fonts_and_maps() {
360        assert_eq!(mime_for("logo.a1b2c3d4.png"), "image/png");
361        assert_eq!(mime_for("photo.jpeg"), "image/jpeg");
362        assert_eq!(mime_for("icon.ico"), "image/x-icon");
363        assert_eq!(mime_for("inter.woff2"), "font/woff2");
364        assert_eq!(mime_for("legacy.woff"), "font/woff");
365        assert_eq!(mime_for("app.js.map"), "application/json");
366    }
367
368    #[test]
369    fn mime_wasm_is_pinned() {
370        // WebAssembly.instantiateStreaming rejects anything else.
371        assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
372    }
373
374    #[test]
375    fn mime_default_stays_fail_closed() {
376        // Pinned so a future widening does not silently swallow these: with
377        // nosniff set, octet-stream is the correct answer for an opaque binary.
378        assert_eq!(mime_for("page.abc12345.ir"), "application/octet-stream");
379        assert_eq!(mime_for("archive.zip"), "application/octet-stream");
380        assert_eq!(mime_for("thing.unknownext"), "application/octet-stream");
381    }
382
383    #[test]
384    fn content_hash_detection_gates_immutable_caching() {
385        assert!(is_content_hashed("app.a1b2c3d4.js"));
386        assert!(is_content_hashed("page.00000000.ir"));
387        // Un-hashed: pinning these for a year means a redeploy cannot
388        // invalidate them.
389        assert!(!is_content_hashed("manifest.json"));
390        assert!(!is_content_hashed("sw.js"));
391        assert!(!is_content_hashed("index.html"));
392        assert!(!is_content_hashed("Inter Variable.woff2"));
393        // Wrong length or non-hex must not count.
394        assert!(!is_content_hashed("app.a1b2c3.js"));
395        assert!(!is_content_hashed("app.a1b2c3d4e5.js"));
396        assert!(!is_content_hashed("app.zzzzzzzz.js"));
397    }
398
399    #[test]
400    fn denied_names_are_never_served() {
401        // manifest.json exposes the route table and asset graph; a direct
402        // .br/.gz request would return compressed bytes with no
403        // Content-Encoding.
404        assert!(is_denied("manifest.json"));
405        assert!(is_denied("app.a1b2c3d4.js.br"));
406        assert!(is_denied("app.a1b2c3d4.js.gz"));
407        assert!(!is_denied("app.a1b2c3d4.js"));
408    }
409
410    // -- Accept-Encoding negotiation ----------------------------------------
411
412    /// Resolve which encoding `serve_asset` would pick, given a header and the
413    /// set of compressed siblings that exist.
414    fn pick(header: &str, has_br: bool, has_gz: bool) -> Option<&'static str> {
415        let lowered = header.to_ascii_lowercase();
416        let accepted = parse_accept_encoding(&lowered);
417        let q_of = |name: &str| {
418            accepted
419                .iter()
420                .find(|e| e.token == name)
421                .map(|e| e.q)
422                .unwrap_or(0.0)
423        };
424        let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
425        candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
426        for (encoding, q) in candidates {
427            if q <= 0.0 {
428                continue;
429            }
430            if (encoding == "br" && has_br) || (encoding == "gzip" && has_gz) {
431                return Some(encoding);
432            }
433        }
434        None
435    }
436
437    #[test]
438    fn qvalue_zero_is_a_rejection_not_an_acceptance() {
439        // The standard way a client or proxy says "I cannot decode brotli".
440        // A substring test read this as acceptance and shipped an undecodable
441        // body — i.e. the whole app bundle failed to load, with no fallback.
442        assert_eq!(pick("gzip, deflate, br;q=0", true, true), Some("gzip"));
443        assert_eq!(pick("gzip;q=0, br", true, true), Some("br"));
444        assert_eq!(pick("gzip;q=0, br;q=0", true, true), None);
445        // Whitespace and an uppercase parameter name must not defeat it.
446        assert_eq!(pick("br; q=0, gzip", true, true), Some("gzip"));
447        assert_eq!(pick("br;Q=0, gzip", true, true), Some("gzip"));
448    }
449
450    #[test]
451    fn higher_qvalue_wins() {
452        assert_eq!(pick("br;q=0.1, gzip;q=0.9", true, true), Some("gzip"));
453        assert_eq!(pick("br;q=0.9, gzip;q=0.1", true, true), Some("br"));
454        // br wins ties, preserving the previous bias.
455        assert_eq!(pick("br, gzip", true, true), Some("br"));
456    }
457
458    #[test]
459    fn tokens_match_exactly_not_by_substring() {
460        // `x-brotli-nope` used to be served brotli.
461        assert_eq!(pick("x-brotli-nope", true, true), None);
462        assert_eq!(pick("gzipped-thing", true, true), None);
463    }
464
465    #[test]
466    fn falls_through_to_the_next_candidate_when_a_sibling_is_missing() {
467        // An asset with a .gz but no .br used to be served RAW to a br+gzip
468        // client.
469        assert_eq!(pick("gzip, deflate, br", false, true), Some("gzip"));
470        assert_eq!(pick("gzip, deflate, br", true, false), Some("br"));
471        assert_eq!(pick("gzip, deflate, br", false, false), None);
472    }
473
474    #[test]
475    fn wildcard_identity_and_absent_headers_serve_raw() {
476        // `*` is deliberately not treated as licence to send brotli.
477        assert_eq!(pick("*", true, true), None);
478        assert_eq!(pick("identity", true, true), None);
479        assert_eq!(pick("", true, true), None);
480    }
481
482    // -- End-to-end handler behavior ----------------------------------------
483
484    #[derive(Embed)]
485    #[folder = "test_fixtures/"]
486    struct Fixtures;
487
488    /// Call the handler with an optional Accept-Encoding header.
489    async fn get(name: &str, accept: Option<&str>) -> Response {
490        let mut headers = HeaderMap::new();
491        if let Some(accept) = accept {
492            headers.insert(header::ACCEPT_ENCODING, accept.parse().unwrap());
493        }
494        serve_asset::<Fixtures>(Path(name.to_string()), headers).await
495    }
496
497    fn header_of(response: &Response, name: &str) -> Option<String> {
498        response
499            .headers()
500            .get(name)
501            .map(|v| v.to_str().unwrap().to_string())
502    }
503
504    #[tokio::test]
505    async fn serves_a_hashed_asset_immutable_with_an_etag() {
506        let response = get("app.a1b2c3d4.js", None).await;
507        assert_eq!(response.status(), StatusCode::OK);
508        assert_eq!(
509            header_of(&response, "content-type").as_deref(),
510            Some("text/javascript; charset=utf-8")
511        );
512        assert_eq!(
513            header_of(&response, "cache-control").as_deref(),
514            Some("public, max-age=31536000, immutable")
515        );
516        assert!(header_of(&response, "etag").is_some());
517        assert_eq!(
518            header_of(&response, "x-content-type-options").as_deref(),
519            Some("nosniff")
520        );
521    }
522
523    #[tokio::test]
524    async fn un_hashed_assets_are_revalidated_not_pinned_for_a_year() {
525        // `immutable` on an un-hashed name means a redeploy cannot invalidate
526        // it — the ETag is what keeps that cheap.
527        let response = get("style.css", None).await;
528        assert_eq!(
529            header_of(&response, "cache-control").as_deref(),
530            Some("public, max-age=0, must-revalidate")
531        );
532        assert!(header_of(&response, "etag").is_some());
533    }
534
535    #[tokio::test]
536    async fn conditional_request_gets_304_without_a_body() {
537        let first = get("style.css", None).await;
538        let etag = header_of(&first, "etag").unwrap();
539
540        let mut headers = HeaderMap::new();
541        headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());
542        let second = serve_asset::<Fixtures>(Path("style.css".to_string()), headers).await;
543        assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
544    }
545
546    #[tokio::test]
547    async fn negotiation_honours_qvalue_zero_end_to_end() {
548        // style.css has a .br sibling but no .gz.
549        let response = get("style.css", Some("gzip, deflate, br")).await;
550        assert_eq!(
551            header_of(&response, "content-encoding").as_deref(),
552            Some("br")
553        );
554
555        // ...and a client that cannot decode brotli must not receive it.
556        let response = get("style.css", Some("gzip, deflate, br;q=0")).await;
557        assert_eq!(header_of(&response, "content-encoding"), None);
558    }
559
560    #[tokio::test]
561    async fn falls_back_to_gzip_when_only_a_gz_sibling_exists() {
562        // app.a1b2c3d4.js has a .gz but no .br — this used to serve raw.
563        let response = get("app.a1b2c3d4.js", Some("gzip, deflate, br")).await;
564        assert_eq!(
565            header_of(&response, "content-encoding").as_deref(),
566            Some("gzip")
567        );
568    }
569
570    #[tokio::test]
571    async fn manifest_and_compressed_siblings_are_not_directly_servable() {
572        // manifest.json exposes the route table and asset graph; a raw .gz
573        // response would be corrupt (compressed bytes, no Content-Encoding).
574        assert_eq!(
575            get("manifest.json", None).await.status(),
576            StatusCode::NOT_FOUND
577        );
578        assert_eq!(
579            get("app.a1b2c3d4.js.gz", None).await.status(),
580            StatusCode::NOT_FOUND
581        );
582        assert_eq!(
583            get("style.css.br", None).await.status(),
584            StatusCode::NOT_FOUND
585        );
586    }
587
588    #[tokio::test]
589    async fn filename_aliases_404_in_debug_just_like_release() {
590        // In debug builds rust-embed reads from the filesystem, so on Windows
591        // these used to resolve (as octet-stream) while 404ing in release —
592        // a dev/prod divergence that hides MIME and cache bugs until deploy.
593        for alias in [
594            "APP.A1B2C3D4.JS",
595            "app.a1b2c3d4.js::$DATA",
596            "app.a1b2c3d4.js.",
597            "app.a1b2c3d4.js ",
598            "./app.a1b2c3d4.js",
599            "../Cargo.toml",
600        ] {
601            assert_eq!(
602                get(alias, None).await.status(),
603                StatusCode::NOT_FOUND,
604                "alias {alias:?} must not resolve"
605            );
606        }
607    }
608
609    #[tokio::test]
610    async fn missing_asset_is_404() {
611        assert_eq!(get("nope.js", None).await.status(), StatusCode::NOT_FOUND);
612    }
613}