1use autumn_web::assets::PluginAssets;
21
22pub const ASSETS_NAMESPACE: &str = "three";
25
26pub const THREE_VERSION: &str = "0.185.1";
28
29pub const THREE_MODULE_URL: &str = "/static/_plugins/three/three.module.min.js";
38
39pub(crate) const CORE_JS: &str = "three.core.min.js";
41pub(crate) const MODULE_JS: &str = "three.module.min.js";
43pub(crate) const ORBIT_CONTROLS_JS: &str = "OrbitControls.js";
45pub(crate) const GLTF_LOADER_JS: &str = "GLTFLoader.js";
47pub(crate) const BUFFER_GEOMETRY_UTILS_JS: &str = "BufferGeometryUtils.js";
49pub(crate) const SKELETON_UTILS_JS: &str = "SkeletonUtils.js";
51pub(crate) const ROOM_ENVIRONMENT_JS: &str = "RoomEnvironment.js";
53pub(crate) const PARSE_JS: &str = "parse.js";
55pub(crate) const INIT_JS: &str = "init.js";
57pub(crate) const THREE_CSS: &str = "three.css";
59
60pub 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
101const BARE_THREE: &str = "} from 'three';";
103const RELATIVE_THREE: &str = "} from './three.module.min.js';";
105const THREE_REWRITE: (&str, &str) = (BARE_THREE, RELATIVE_THREE);
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub struct VendoredFile {
112 pub path: &'static str,
114 pub source: &'static str,
116 pub upstream_integrity: &'static str,
118 pub rewrites: &'static [(&'static str, &'static str)],
125}
126
127pub 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 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 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 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 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 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 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}