Skip to main content

autumn_plugin_three/
plugin.rs

1//! [`ThreePlugin`]: installs the Three.js assets in an Autumn app.
2//!
3//! The plugin installs [`THREE_ASSETS`] through `AppBuilder::plugin_assets`.
4//! It reads no configuration and adds no startup hooks.
5
6use std::borrow::Cow;
7
8use autumn_web::app::AppBuilder;
9use autumn_web::plugin::Plugin;
10
11use crate::assets::THREE_ASSETS;
12
13/// The plugin name in Autumn diagnostics.
14pub const PLUGIN_NAME: &str = "autumn-plugin-three";
15
16/// Installs the Three.js assets in an Autumn app.
17///
18/// ```rust,no_run
19/// use autumn_plugin_three::ThreePlugin;
20///
21/// # async fn run() {
22/// autumn_web::app()
23///     .plugin(ThreePlugin::new())
24///     .run()
25///     .await;
26/// # }
27/// ```
28///
29/// Then put [`three_script`](crate::three_script) and
30/// [`three_stylesheet`](crate::three_stylesheet) in the page `<head>`.
31#[derive(Debug, Default)]
32#[must_use]
33pub struct ThreePlugin;
34
35impl ThreePlugin {
36    /// Makes the plugin. It reads no configuration.
37    pub const fn new() -> Self {
38        Self
39    }
40}
41
42impl Plugin for ThreePlugin {
43    fn name(&self) -> Cow<'static, str> {
44        Cow::Borrowed(PLUGIN_NAME)
45    }
46
47    fn build(self, app: AppBuilder) -> AppBuilder {
48        app.plugin_assets(&THREE_ASSETS)
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use crate::assets::{CORE_JS, INIT_JS, MODULE_JS, THREE_ASSETS, THREE_CSS};
56    use autumn_web::assets::{PLUGIN_ASSETS_ROUTE_MARKER, asset_url};
57    use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
58    use autumn_web::route_listing::{RouteClassification, RouteSource};
59    use autumn_web::test::{TestApp, TestClient};
60
61    const JS: &str = "text/javascript; charset=utf-8";
62    const CSS: &str = "text/css; charset=utf-8";
63    const IMMUTABLE: &str = "public, max-age=31536000, immutable";
64    const REVALIDATE: &str = "public, max-age=0, must-revalidate";
65
66    fn client() -> TestClient {
67        TestApp::new().plugin(ThreePlugin::new()).build()
68    }
69
70    fn url(path: &str) -> String {
71        THREE_ASSETS.url(path)
72    }
73
74    #[tokio::test]
75    async fn every_file_serves_at_its_fingerprinted_url() {
76        let client = client();
77        for asset in THREE_ASSETS.iter() {
78            let response = client.get(asset.url()).send().await;
79            let is_css = std::path::Path::new(asset.logical_path())
80                .extension()
81                .is_some_and(|e| e.eq_ignore_ascii_case("css"));
82            let expected = if is_css { CSS } else { JS };
83            response
84                .assert_ok()
85                .assert_header("content-type", expected)
86                .assert_header("cache-control", IMMUTABLE);
87            assert_eq!(
88                response.body.as_slice(),
89                asset.bytes(),
90                "{}",
91                asset.logical_path()
92            );
93        }
94    }
95
96    #[tokio::test]
97    async fn module_files_serve_at_plain_urls_with_etags() {
98        // Module imports resolve to plain URLs. They must work.
99        let client = client();
100        for path in [CORE_JS, MODULE_JS, INIT_JS, THREE_CSS] {
101            let plain = format!("/static/_plugins/three/{path}");
102            let response = client.get(&plain).send().await;
103            response
104                .assert_ok()
105                .assert_header("cache-control", REVALIDATE);
106            let etag = response.header("etag").expect("etag").to_owned();
107            client
108                .get(&plain)
109                .header("if-none-match", &etag)
110                .send()
111                .await
112                .assert_status(304);
113        }
114    }
115
116    #[tokio::test]
117    async fn unbundled_and_stale_paths_are_not_found() {
118        let client = client();
119        for path in [
120            "/static/_plugins/three/manifest.json",
121            "/static/_plugins/three/THREE-LICENSE",
122            "/static/_plugins/three/init.00000000.js",
123            "/static/_plugins/three/three.webgpu.js",
124        ] {
125            client.get(path).send().await.assert_status(404);
126        }
127    }
128
129    #[tokio::test]
130    async fn asset_url_resolves_the_installed_bundle() {
131        let _client = client();
132        for path in [CORE_JS, MODULE_JS, INIT_JS, THREE_CSS] {
133            assert_eq!(asset_url(&format!("_plugins/three/{path}")), url(path));
134        }
135    }
136
137    #[test]
138    fn bundle_routes_are_public_plugin_routes() {
139        let app = autumn_web::app().plugin(ThreePlugin::new());
140        let infos = app.plugin_route_infos().expect("route infos");
141        let routes: Vec<_> = infos
142            .iter()
143            .filter(|info| info.path.starts_with("/static/_plugins/three/"))
144            .collect();
145        assert_eq!(routes.len(), 20, "ten files, two URLs each: {infos:?}");
146        for info in routes {
147            assert_eq!(info.method, "GET");
148            assert_eq!(info.classification, RouteClassification::Public);
149            assert_eq!(info.middleware, [PLUGIN_ASSETS_ROUTE_MARKER]);
150            assert_eq!(info.source, RouteSource::Plugin(PLUGIN_NAME.to_owned()));
151        }
152    }
153
154    #[test]
155    fn plugin_passes_conformance() {
156        let app = autumn_web::app().plugin(ThreePlugin::new());
157        let infos = app.plugin_route_infos().expect("route infos");
158        let report = run_conformance(&ConformanceConfig::new(PLUGIN_NAME), &infos);
159        assert!(report.passed(), "{}", report.to_text_report());
160    }
161
162    #[tokio::test]
163    async fn installing_the_plugin_twice_is_harmless() {
164        let client = TestApp::new()
165            .plugin(ThreePlugin::new())
166            .plugin(ThreePlugin::new())
167            .build();
168        client.get(&url(INIT_JS)).send().await.assert_ok();
169    }
170}