Skip to main content

autumn_plugin_three/
assets.rs

1//! Vendored Three.js assets, embedded at compile time.
2//!
3//! The bundle holds:
4//!
5//! - Three.js core: `three.core.min.js` and `three.module.min.js`
6//!   (upstream bytes, unchanged).
7//! - Addons: `OrbitControls.js`, `GLTFLoader.js`, `BufferGeometryUtils.js`,
8//!   `SkeletonUtils.js`, `RoomEnvironment.js`. Only their import specifiers
9//!   change (see [`VendoredFile::rewrites`]).
10//! - Plugin files: `parse.js`, `init.js`, `three.css`.
11//!
12//! [`ThreePlugin`](crate::ThreePlugin) installs [`THREE_ASSETS`] through
13//! `AppBuilder::plugin_assets`. Autumn serves each file under
14//! `/static/_plugins/three/` at a hashed URL (immutable) and at its plain
15//! URL (`must-revalidate`). Autumn computes the SRI hashes.
16//!
17//! `manifest.json` and `THREE-LICENSE` are not in the bundle. They are not
18//! served.
19
20use autumn_web::assets::PluginAssets;
21
22/// URL namespace of the bundle. Files are served under
23/// `/static/_plugins/three/`.
24pub const ASSETS_NAMESPACE: &str = "three";
25
26/// Pinned Three.js version.
27pub const THREE_VERSION: &str = "0.185.1";
28
29/// Plain URL of the Three.js ES module.
30///
31/// Import Three.js from this URL in your own module scripts. Then your code
32/// and the plugin share one Three.js instance:
33///
34/// ```js
35/// import * as THREE from "/static/_plugins/three/three.module.min.js";
36/// ```
37pub const THREE_MODULE_URL: &str = "/static/_plugins/three/three.module.min.js";
38
39/// Three.js core (classes and math).
40pub(crate) const CORE_JS: &str = "three.core.min.js";
41/// Three.js module (renderers). Imports [`CORE_JS`].
42pub(crate) const MODULE_JS: &str = "three.module.min.js";
43/// Orbit camera controls addon.
44pub(crate) const ORBIT_CONTROLS_JS: &str = "OrbitControls.js";
45/// glTF / GLB loader addon.
46pub(crate) const GLTF_LOADER_JS: &str = "GLTFLoader.js";
47/// Geometry helpers. [`GLTF_LOADER_JS`] imports it.
48pub(crate) const BUFFER_GEOMETRY_UTILS_JS: &str = "BufferGeometryUtils.js";
49/// Skeleton helpers. [`GLTF_LOADER_JS`] imports it.
50pub(crate) const SKELETON_UTILS_JS: &str = "SkeletonUtils.js";
51/// Room environment (image-based light) addon.
52pub(crate) const ROOM_ENVIRONMENT_JS: &str = "RoomEnvironment.js";
53/// Plugin attribute parsers (no Three.js dependency).
54pub(crate) const PARSE_JS: &str = "parse.js";
55/// Plugin runtime: scans `[data-three="scene"]` and builds scenes.
56pub(crate) const INIT_JS: &str = "init.js";
57/// Plugin default styles.
58pub(crate) const THREE_CSS: &str = "three.css";
59
60/// The plugin asset bundle.
61///
62/// [`ThreePlugin`](crate::ThreePlugin) installs it. Use it directly only to
63/// make URLs or tags yourself:
64///
65/// ```rust
66/// use autumn_plugin_three::THREE_ASSETS;
67///
68/// let url = THREE_ASSETS.url("init.js");
69/// assert!(url.starts_with("/static/_plugins/three/init."), "{url}");
70/// let sri = THREE_ASSETS.integrity("init.js").expect("init.js is bundled");
71/// assert!(sri.starts_with("sha384-"));
72/// ```
73pub static THREE_ASSETS: PluginAssets = PluginAssets::from_files(
74    ASSETS_NAMESPACE,
75    &[
76        (CORE_JS, include_bytes!("../assets/three.core.min.js")),
77        (MODULE_JS, include_bytes!("../assets/three.module.min.js")),
78        (
79            ORBIT_CONTROLS_JS,
80            include_bytes!("../assets/OrbitControls.js"),
81        ),
82        (GLTF_LOADER_JS, include_bytes!("../assets/GLTFLoader.js")),
83        (
84            BUFFER_GEOMETRY_UTILS_JS,
85            include_bytes!("../assets/BufferGeometryUtils.js"),
86        ),
87        (
88            SKELETON_UTILS_JS,
89            include_bytes!("../assets/SkeletonUtils.js"),
90        ),
91        (
92            ROOM_ENVIRONMENT_JS,
93            include_bytes!("../assets/RoomEnvironment.js"),
94        ),
95        (PARSE_JS, include_bytes!("../assets/parse.js")),
96        (INIT_JS, include_bytes!("../assets/init.js")),
97        (THREE_CSS, include_bytes!("../assets/three.css")),
98    ],
99);
100
101/// Upstream `import ... from 'three'` line end.
102const BARE_THREE: &str = "} from 'three';";
103/// Served replacement for [`BARE_THREE`].
104const RELATIVE_THREE: &str = "} from './three.module.min.js';";
105/// The rewrite every addon gets.
106const THREE_REWRITE: (&str, &str) = (BARE_THREE, RELATIVE_THREE);
107
108/// Provenance of one vendored upstream file.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub struct VendoredFile {
112    /// Logical path in [`THREE_ASSETS`].
113    pub path: &'static str,
114    /// Upstream URL (jsDelivr, pinned version).
115    pub source: &'static str,
116    /// `sha384` SRI of the upstream bytes, before the import rewrites.
117    pub upstream_integrity: &'static str,
118    /// Import rewrites, as `(upstream text, served text)`.
119    ///
120    /// Addons import the bare specifier `three` and `../utils/*.js`. These
121    /// need an import map, which is an inline script. The default Autumn CSP
122    /// blocks inline scripts, so `scripts/vendor.sh` makes the imports
123    /// relative. Keep this table and `scripts/vendor.sh` in sync.
124    pub rewrites: &'static [(&'static str, &'static str)],
125}
126
127/// The vendored upstream files and their pins.
128///
129/// A test checks each pin against the bundled bytes, after it reverses
130/// the [`VendoredFile::rewrites`].
131pub const VENDORED: &[VendoredFile] = &[
132    VendoredFile {
133        path: CORE_JS,
134        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.core.min.js",
135        upstream_integrity: "sha384-rx+KIp/9ptjArhnFAcpVoOc/ynktDsRtRJKIbC7YVKylEvFu8sgmzk9RmQ+CIV48",
136        rewrites: &[],
137    },
138    VendoredFile {
139        path: MODULE_JS,
140        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.min.js",
141        upstream_integrity: "sha384-QHQk1LzjJlJYNdthXjKCmffpDRZL3EqJ7LfqBzyKyvGgjAYM2ZVuYtFGg42NcAJ/",
142        rewrites: &[],
143    },
144    VendoredFile {
145        path: ORBIT_CONTROLS_JS,
146        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/controls/OrbitControls.js",
147        upstream_integrity: "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
148        rewrites: &[THREE_REWRITE],
149    },
150    VendoredFile {
151        path: GLTF_LOADER_JS,
152        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/loaders/GLTFLoader.js",
153        upstream_integrity: "sha384-3CnKaFWE2emo2DOUQi/yFm4SMemUgSZ9IAJe/V2pyJTw9KXWYSmR0MiX/7RoPyiJ",
154        rewrites: &[
155            THREE_REWRITE,
156            (
157                "from '../utils/BufferGeometryUtils.js';",
158                "from './BufferGeometryUtils.js';",
159            ),
160            (
161                "from '../utils/SkeletonUtils.js';",
162                "from './SkeletonUtils.js';",
163            ),
164        ],
165    },
166    VendoredFile {
167        path: BUFFER_GEOMETRY_UTILS_JS,
168        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/utils/BufferGeometryUtils.js",
169        upstream_integrity: "sha384-05mkYituMJObxUkTK7xbW9SA45DEaxOge7FQUGrhW3dsFz0fckzykM6RMAPN26rD",
170        rewrites: &[THREE_REWRITE],
171    },
172    VendoredFile {
173        path: SKELETON_UTILS_JS,
174        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/utils/SkeletonUtils.js",
175        upstream_integrity: "sha384-Pozn8j5+YFr3ak8Pm90ayqDrGYn/DV7vVs/YIIqzJhzeJT0LQksoS1fZQ5lfsYlw",
176        rewrites: &[THREE_REWRITE],
177    },
178    VendoredFile {
179        path: ROOM_ENVIRONMENT_JS,
180        source: "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/environments/RoomEnvironment.js",
181        upstream_integrity: "sha384-/H49oz0ZtMgJNgMZ+OhhuMuKBOsaiC3kY0/PZSvgJJXAOJJwmSBJjzlV5lJul3MB",
182        rewrites: &[THREE_REWRITE],
183    },
184];
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use base64::Engine as _;
190    use sha2::{Digest as _, Sha384};
191
192    /// Computes the `sha384` SRI of bytes.
193    fn sri(bytes: &[u8]) -> String {
194        let digest = Sha384::digest(bytes);
195        format!(
196            "sha384-{}",
197            base64::engine::general_purpose::STANDARD.encode(digest)
198        )
199    }
200
201    /// True when `path` has the extension `ext` (any case).
202    fn has_ext(path: &str, ext: &str) -> bool {
203        std::path::Path::new(path)
204            .extension()
205            .is_some_and(|e| e.eq_ignore_ascii_case(ext))
206    }
207
208    /// The bundled bytes of `path` as text.
209    fn text(path: &str) -> &'static str {
210        let asset = THREE_ASSETS.get(path).expect("file is bundled");
211        std::str::from_utf8(asset.bytes()).expect("bundled text is UTF-8")
212    }
213
214    /// Module specifiers in `source`: static `import`/`export ... from "x"`
215    /// and dynamic `import("x")`. A static `from` counts only after `}` or
216    /// on a line that starts with `import` or `export`. Thus text in strings
217    /// (`from "srgb-linear"`) does not count. The scan skips comment lines.
218    fn specifiers(source: &str) -> Vec<String> {
219        let mut out = Vec::new();
220        for line in source.lines() {
221            let trimmed = line.trim_start();
222            if trimmed.starts_with('*') || trimmed.starts_with("//") || trimmed.starts_with("/*") {
223                continue;
224            }
225            let statement = trimmed.starts_with("import") || trimmed.starts_with("export");
226            for marker in ["from", "import("] {
227                let mut start = 0;
228                while let Some(at) = line[start..].find(marker) {
229                    let before = line[..start + at].trim_end();
230                    start += at + marker.len();
231                    if marker == "from" && !(statement || before.ends_with('}')) {
232                        continue;
233                    }
234                    let after = line[start..].trim_start();
235                    let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'')
236                    else {
237                        continue;
238                    };
239                    if let Some(end) = after[1..].find(quote) {
240                        out.push(after[1..=end].to_owned());
241                    }
242                }
243            }
244        }
245        out
246    }
247
248    #[test]
249    fn specifier_scan_finds_imports_and_skips_strings() {
250        let source = "import { a } from './a.js';\n} from \"./b.js\";\nx}from\"./c.js\"\n\
251                      const m = await import(\"./d.js\");\nwarn(`from \"srgb\"`);\n * from 'three'";
252        assert_eq!(specifiers(source), ["./a.js", "./b.js", "./c.js", "./d.js"]);
253    }
254
255    #[test]
256    fn bundle_holds_exactly_the_served_files() {
257        let mut files: Vec<&str> = THREE_ASSETS
258            .iter()
259            .map(autumn_web::assets::PluginAsset::logical_path)
260            .collect();
261        files.sort_unstable();
262        let mut expected = vec![
263            BUFFER_GEOMETRY_UTILS_JS,
264            GLTF_LOADER_JS,
265            ORBIT_CONTROLS_JS,
266            ROOM_ENVIRONMENT_JS,
267            SKELETON_UTILS_JS,
268            INIT_JS,
269            PARSE_JS,
270            THREE_CSS,
271            CORE_JS,
272            MODULE_JS,
273        ];
274        expected.sort_unstable();
275        // `manifest.json` and `THREE-LICENSE` are not served.
276        assert_eq!(files, expected);
277        assert_eq!(THREE_ASSETS.namespace(), ASSETS_NAMESPACE);
278        assert_eq!(THREE_ASSETS.mount_path(), "/static/_plugins/three");
279    }
280
281    #[test]
282    fn bundle_integrity_matches_embedded_bytes() {
283        for asset in THREE_ASSETS.iter() {
284            assert_eq!(
285                asset.integrity(),
286                sri(asset.bytes()),
287                "{}",
288                asset.logical_path()
289            );
290        }
291    }
292
293    #[test]
294    fn vendored_files_match_upstream_after_reversing_rewrites() {
295        assert_eq!(VENDORED.len(), 7, "two core files and five addons");
296        for file in VENDORED {
297            let mut source = text(file.path).to_owned();
298            for (upstream, served) in file.rewrites {
299                assert_eq!(
300                    source.matches(served).count(),
301                    1,
302                    "{}: rewrite target {served:?} is unique",
303                    file.path
304                );
305                source = source.replace(served, upstream);
306            }
307            assert_eq!(
308                sri(source.as_bytes()),
309                file.upstream_integrity,
310                "{} matches upstream",
311                file.path
312            );
313            assert!(
314                file.source.starts_with(&format!(
315                    "https://cdn.jsdelivr.net/npm/three@{THREE_VERSION}/"
316                )),
317                "{}",
318                file.source
319            );
320        }
321    }
322
323    #[test]
324    fn core_files_are_unchanged_and_addons_are_rewritten() {
325        for file in VENDORED {
326            let is_core = file.path == CORE_JS || file.path == MODULE_JS;
327            assert_eq!(file.rewrites.is_empty(), is_core, "{}", file.path);
328            for (upstream, served) in file.rewrites {
329                // A rewrite may only change an import specifier:
330                // `from '<spec>';` → `from './<bundled file>';`.
331                let spec = |text: &'static str| {
332                    text.strip_prefix("} ")
333                        .unwrap_or(text)
334                        .strip_prefix("from '")
335                        .and_then(|rest| rest.strip_suffix("';"))
336                        .filter(|spec| !spec.contains(['\'', ';', ' ']))
337                };
338                let from = spec(upstream).unwrap_or_else(|| panic!("{upstream:?}"));
339                let to = spec(served).unwrap_or_else(|| panic!("{served:?}"));
340                assert_ne!(from, to);
341                let target = to.strip_prefix("./").expect("relative target");
342                assert!(THREE_ASSETS.get(target).is_some(), "{target} is bundled");
343                assert_eq!(
344                    upstream.starts_with("} "),
345                    served.starts_with("} "),
346                    "same statement shape"
347                );
348            }
349        }
350    }
351
352    #[test]
353    fn every_module_import_resolves_inside_the_bundle() {
354        let mut checked = 0;
355        for asset in THREE_ASSETS.iter() {
356            let path = asset.logical_path();
357            if !has_ext(path, "js") {
358                continue;
359            }
360            for spec in specifiers(text(path)) {
361                let target = spec
362                    .strip_prefix("./")
363                    .unwrap_or_else(|| panic!("{path}: bare or parent import {spec:?}"));
364                assert!(
365                    THREE_ASSETS.get(target).is_some(),
366                    "{path}: import {spec:?} is not bundled"
367                );
368                checked += 1;
369            }
370        }
371        assert!(checked >= 13, "the scan finds the imports ({checked})");
372    }
373
374    #[test]
375    fn urls_are_fingerprinted_under_the_plugin_mount() {
376        for asset in THREE_ASSETS.iter() {
377            let path = asset.logical_path();
378            assert_eq!(asset.plain_url(), format!("/static/_plugins/three/{path}"));
379            let (stem, ext) = path.rsplit_once('.').expect("extension");
380            let hash = asset
381                .url()
382                .strip_prefix(&format!("/static/_plugins/three/{stem}."))
383                .and_then(|rest| rest.strip_suffix(&format!(".{ext}")))
384                .expect("fingerprinted form");
385            assert_eq!(hash.len(), 8);
386            assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
387        }
388    }
389
390    #[test]
391    fn module_url_is_the_plain_url_of_the_three_module() {
392        assert_eq!(
393            THREE_MODULE_URL,
394            THREE_ASSETS.get(MODULE_JS).expect("bundled").plain_url()
395        );
396    }
397
398    #[test]
399    fn content_types_match_the_files() {
400        for asset in THREE_ASSETS.iter() {
401            let expected = if has_ext(asset.logical_path(), "css") {
402                "text/css; charset=utf-8"
403            } else {
404                "text/javascript; charset=utf-8"
405            };
406            assert_eq!(asset.content_type(), expected, "{}", asset.logical_path());
407        }
408    }
409
410    #[test]
411    fn manifest_agrees_with_constants() {
412        let manifest = include_str!("../assets/manifest.json");
413        assert!(manifest.contains(&format!("\"version\": \"{THREE_VERSION}\"")));
414        for file in VENDORED {
415            assert!(manifest.contains(file.source), "{}", file.source);
416            assert!(
417                manifest.contains(file.upstream_integrity),
418                "{}",
419                file.upstream_integrity
420            );
421        }
422    }
423
424    #[test]
425    fn vendor_script_agrees_with_constants() {
426        let script = include_str!("../scripts/vendor.sh");
427        assert!(script.contains(&format!("VERSION=\"{THREE_VERSION}\"")));
428        for file in VENDORED {
429            let hash = file.upstream_integrity.trim_start_matches("sha384-");
430            assert!(script.contains(hash), "{} pin in vendor.sh", file.path);
431        }
432    }
433}