diff --git a/packages/cli-opt/src/hash.rs b/packages/cli-opt/src/hash.rs index 53ba60809..db6cd80f3 100644 --- a/packages/cli-opt/src/hash.rs +++ b/packages/cli-opt/src/hash.rs @@ -145,7 +145,8 @@ pub fn add_hash_to_asset(asset: &mut BundledAsset) { .map(|byte| format!("{byte:x}")) .collect::(); let file_stem = source_path.file_stem().unwrap_or(file_name); - let mut bundled_path = PathBuf::from(format!("{}-{hash}", file_stem.to_string_lossy())); + let mut bundled_path = + PathBuf::from(format!("{}-dxh{hash}", file_stem.to_string_lossy())); if let Some(ext) = ext { bundled_path.set_extension(ext); diff --git a/packages/playwright-tests/cli-optimization/.gitignore b/packages/playwright-tests/cli-optimization/.gitignore index 613fd2f89..0fdbab736 100644 --- a/packages/playwright-tests/cli-optimization/.gitignore +++ b/packages/playwright-tests/cli-optimization/.gitignore @@ -1,3 +1,4 @@ dist target -monaco-editor-0.52.2 \ No newline at end of file +monaco-editor +partial-monaco-editor \ No newline at end of file diff --git a/packages/playwright-tests/cli-optimization/build.rs b/packages/playwright-tests/cli-optimization/build.rs index 788ed1ed6..5f93ba74c 100644 --- a/packages/playwright-tests/cli-optimization/build.rs +++ b/packages/playwright-tests/cli-optimization/build.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; fn main() { // If the monaco editor folder doesn't exist, download it - let monaco_path = PathBuf::from("monaco-editor-0.52.2"); + let monaco_path = PathBuf::from("monaco-editor"); if monaco_path.exists() { return; } @@ -10,5 +10,7 @@ fn main() { let url = "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz"; let bytes = reqwest::blocking::get(url).unwrap().bytes().unwrap(); let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_ref())); - archive.unpack(&monaco_path).unwrap(); + let monaco_path_partial = PathBuf::from("partial-monaco-editor"); + archive.unpack(&monaco_path_partial).unwrap(); + std::fs::rename(monaco_path_partial, monaco_path).unwrap(); } diff --git a/packages/playwright-tests/cli-optimization/src/main.rs b/packages/playwright-tests/cli-optimization/src/main.rs index d0301434b..40c63691f 100644 --- a/packages/playwright-tests/cli-optimization/src/main.rs +++ b/packages/playwright-tests/cli-optimization/src/main.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; -const MONACO_FOLDER: Asset = asset!("/monaco-editor-0.52.2/package/min/vs"); +const MONACO_FOLDER: Asset = asset!("/monaco-editor/package/min/vs"); const SOME_IMAGE: Asset = asset!("/images/toasts.png", ImageAssetOptions::new().with_avif()); const SOME_IMAGE_WITH_THE_SAME_URL: Asset = asset!("/images/toasts.png", ImageAssetOptions::new().with_jpg()); diff --git a/packages/playwright-tests/fullstack.spec.js b/packages/playwright-tests/fullstack.spec.js index 3c688ba57..cfc88b7b5 100644 --- a/packages/playwright-tests/fullstack.spec.js +++ b/packages/playwright-tests/fullstack.spec.js @@ -69,6 +69,46 @@ test("document elements", async ({ page }) => { await expect(main).toHaveCSS("font-family", "Roboto"); }); +test("assets cache correctly", async ({ page }) => { + // Wait for the hashed image to be loaded + const hashedImageFuture = page.waitForResponse((resp) => { + console.log("Response URL:", resp.url()); + return resp.url().includes("/assets/image-") && resp.status() === 200; + }); + + // Navigate to the page that includes the image. + await page.goto("http://localhost:3333"); + + const hashedImageResponse = await hashedImageFuture; + + // Make sure the hashed image cache control header is set to immutable + const cacheControl = hashedImageResponse.headers()["cache-control"]; + console.log("Cache-Control header:", cacheControl); + expect(cacheControl).toContain("immutable"); + + // TODO: raw assets support was removed and needs to be restored + // https://github.com/DioxusLabs/dioxus/issues/4115 + // // Wait for the asset image to be loaded + // const assetImageResponse = await page.waitForResponse( + // (resp) => resp.url().includes("/assets/image.png") && resp.status() === 200 + // ); + // // Make sure the asset image cache control header does not contain immutable + // const assetCacheControl = assetImageResponse.headers()["cache-control"]; + // console.log("Cache-Control header:", assetCacheControl); + // expect(assetCacheControl).not.toContain("immutable"); + + // // Wait for the nested asset image to be loaded + // const nestedAssetImageResponse = await page.waitForResponse( + // (resp) => + // resp.url().includes("/assets/nested/image.png") && resp.status() === 200 + // ); + // // Make sure the nested asset image cache control header does not contain immutable + // const nestedAssetCacheControl = + // nestedAssetImageResponse.headers()["cache-control"]; + // console.log("Cache-Control header:", nestedAssetCacheControl); + // expect(nestedAssetCacheControl).not.toContain("immutable"); +}); + test("websockets", async ({ page }) => { await page.goto("http://localhost:3333"); // wait until the websocket div is mounted diff --git a/packages/playwright-tests/fullstack/Dioxus.toml b/packages/playwright-tests/fullstack/Dioxus.toml new file mode 100644 index 000000000..b7c7db794 --- /dev/null +++ b/packages/playwright-tests/fullstack/Dioxus.toml @@ -0,0 +1,3 @@ +[application] +# resource (public) file folder +asset_dir = "assets" diff --git a/packages/playwright-tests/fullstack/assets/image.png b/packages/playwright-tests/fullstack/assets/image.png new file mode 100644 index 000000000..4d5779048 Binary files /dev/null and b/packages/playwright-tests/fullstack/assets/image.png differ diff --git a/packages/playwright-tests/fullstack/assets/nested/image.png b/packages/playwright-tests/fullstack/assets/nested/image.png new file mode 100644 index 000000000..4d5779048 Binary files /dev/null and b/packages/playwright-tests/fullstack/assets/nested/image.png differ diff --git a/packages/playwright-tests/fullstack/src/main.rs b/packages/playwright-tests/fullstack/src/main.rs index f5689758d..89124c163 100644 --- a/packages/playwright-tests/fullstack/src/main.rs +++ b/packages/playwright-tests/fullstack/src/main.rs @@ -50,6 +50,7 @@ fn app() -> Element { OnMounted {} DefaultServerFnCodec {} DocumentElements {} + Assets {} WebSockets {} } } @@ -165,6 +166,24 @@ fn DocumentElements() -> Element { } } +/// Make sure assets in the assets folder are served correctly and hashed assets are cached forever +#[component] +fn Assets() -> Element { + rsx! { + img { + src: asset!("/assets/image.png"), + } + // TODO: raw assets support was removed and needs to be restored + // https://github.com/DioxusLabs/dioxus/issues/4115 + // img { + // src: "/assets/image.png", + // } + // img { + // src: "/assets/nested/image.png", + // } + } +} + #[server(protocol = Websocket)] async fn echo_ws( input: BoxedStream, diff --git a/packages/server/src/server.rs b/packages/server/src/server.rs index 689319441..433c01799 100644 --- a/packages/server/src/server.rs +++ b/packages/server/src/server.rs @@ -8,10 +8,15 @@ use axum::{ http::{Request, Response, StatusCode}, response::IntoResponse, }; + use dioxus_lib::prelude::{Element, VirtualDom}; use http::header::*; use server_fn::ServerFnTraitObj; +use std::path::Path; use std::sync::Arc; +use tower::util::MapResponse; +use tower::ServiceExt; +use tower_http::services::fs::ServeFileSystemResponseBody; /// A extension trait with utilities for integrating Dioxus with your Axum router. pub trait DioxusRouterExt: DioxusRouterFnExt { @@ -72,9 +77,7 @@ impl DioxusRouterExt for Router where S: Send + Sync + Clone + 'static, { - fn serve_static_assets(mut self) -> Self { - use tower_http::services::{ServeDir, ServeFile}; - + fn serve_static_assets(self) -> Self { let public_path = crate::public_path(); if !public_path.exists() { @@ -82,38 +85,7 @@ where } // Serve all files in public folder except index.html - let dir = std::fs::read_dir(&public_path).unwrap_or_else(|e| { - panic!( - "Couldn't read public directory at {:?}: {}", - &public_path, e - ) - }); - - for entry in dir.flatten() { - let path = entry.path(); - if path.ends_with("index.html") { - continue; - } - let route = path - .strip_prefix(&public_path) - .unwrap() - .iter() - .map(|segment| { - segment.to_str().unwrap_or_else(|| { - panic!("Failed to convert path segment {:?} to string", segment) - }) - }) - .collect::>() - .join("/"); - let route = format!("/{}", route); - if path.is_dir() { - self = self.nest_service(&route, ServeDir::new(path).precompressed_br()); - } else { - self = self.nest_service(&route, ServeFile::new(path).precompressed_br()); - } - } - - self + serve_dir_cached(self, &public_path, &public_path) } fn serve_dioxus_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self { @@ -469,3 +441,90 @@ fn report_err(e: E) -> Response { .body(body::Body::new(format!("Error: {}", e))) .unwrap() } + +fn serve_dir_cached( + mut router: Router, + public_path: &std::path::Path, + directory: &std::path::Path, +) -> Router +where + S: Send + Sync + Clone + 'static, +{ + use tower_http::services::ServeFile; + + let dir = std::fs::read_dir(directory) + .unwrap_or_else(|e| panic!("Couldn't read public directory at {:?}: {}", &directory, e)); + + for entry in dir.flatten() { + let path = entry.path(); + // Don't serve the index.html file. The SSR handler will generate it. + if path == public_path.join("index.html") { + continue; + } + let route = path.strip_prefix(public_path).unwrap(); + let route = path_components_to_route_lossy(route); + + if path.is_dir() { + router = serve_dir_cached(router, public_path, &path); + } else { + let serve_file = ServeFile::new(&path).precompressed_br(); + // All cached assets are served at the root of the asset directory. If we know an asset + // is hashed for cache busting, we can cache the response on the client side forever. If + // the asset changes, the hash in the path will also change and the client will refetch it. + if file_name_looks_immutable(&route) { + router = router.nest_service(&route, cache_response_forever(serve_file)) + } else { + router = router.nest_service(&route, serve_file) + } + } + } + + router +} + +fn file_name_looks_immutable(file_name: &str) -> bool { + // Check if the file name looks like a hash (e.g., "main-dxh12345678.js") + file_name.rsplit_once("-dxh").is_some_and(|(_, hash)| { + hash.chars() + .take_while(|c| *c != '.') + .all(|c| c.is_ascii_hexdigit()) + }) +} + +#[test] +fn test_file_name_looks_immutable() { + assert!(file_name_looks_immutable("main-dxh12345678.js")); + assert!(file_name_looks_immutable("style-dxhabcdef.css")); + assert!(!file_name_looks_immutable("index.html")); + assert!(!file_name_looks_immutable("script.js")); + assert!(!file_name_looks_immutable("main-dxh1234wyz.js")); + assert!(!file_name_looks_immutable("main-dxh12345678-invalid.js")); +} + +fn path_components_to_route_lossy(path: &Path) -> String { + let route = path + .iter() + .map(|segment| segment.to_string_lossy()) + .collect::>() + .join("/"); + format!("/{}", route) +} + +type MappedAxumService = MapResponse< + S, + fn(Response) -> Response, +>; + +fn cache_response_forever< + S: ServiceExt, Response = Response>, +>( + service: S, +) -> MappedAxumService { + service.map_response(|mut response: Response| { + response.headers_mut().insert( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + response + }) +}