From 76ffcabd0be84ace2c131f625da420adb8c98441 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Fri, 20 Jun 2025 00:18:55 -0500 Subject: [PATCH] Cache hashed assets forever in fullstack (#3928) * Cache hashed assets forever in fullstack * fix clippy * fix fullstack test port * fix fullstack clippy * fix double insertion of meta in fullstack test * pull out head element changes * Fix cli-optimization test when interrupted * remove tests for raw assets * fix playwright tests * look for hash in asset name instead of building a manifest * undo changes to cli * fix clippy * fix test_file_name_looks_immutable --- packages/cli-opt/src/hash.rs | 3 +- .../cli-optimization/.gitignore | 3 +- .../cli-optimization/build.rs | 6 +- .../cli-optimization/src/main.rs | 2 +- packages/playwright-tests/fullstack.spec.js | 40 ++++++ .../playwright-tests/fullstack/Dioxus.toml | 3 + .../fullstack/assets/image.png | Bin 0 -> 3291 bytes .../fullstack/assets/nested/image.png | Bin 0 -> 3291 bytes .../playwright-tests/fullstack/src/main.rs | 19 +++ packages/server/src/server.rs | 129 +++++++++++++----- 10 files changed, 165 insertions(+), 40 deletions(-) create mode 100644 packages/playwright-tests/fullstack/Dioxus.toml create mode 100644 packages/playwright-tests/fullstack/assets/image.png create mode 100644 packages/playwright-tests/fullstack/assets/nested/image.png 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 0000000000000000000000000000000000000000..4d577904815105de1161eb680c3343ff9c452feb GIT binary patch literal 3291 zcmV<13?%c3P)X+uL$Nkc;* zP;zf(X>4Tx07!|IR|i;A$rhelQb}k51f+!C1Q96#q=e7{NH2nokOT-N#z0W9ql*fz zT|~f!u8JsjK*&GAUd5M4^8p~rd5bCe5;=wy=tyCJ5HFPD3?;6T<1{7Sqp*pO zqERym=_eMve_t|6sz)u)~8E3Y!!cwCsp{B`+6YDPGGC_UuO!rDt%17 zFf%?0Vk*Rz1){`Ah}9u>%FB)mSJ{jz%;ZE4*hpou^7t(wjunbS0+jk#LAo?JR%Kt3 zn>R3DY-6T~8>zzk@-yQ@mHEM1_&i1Ya4y(op_rX8pd-v16EQFsfiN&gId`l}C{Bn| z>3opn2gEAJ;#5gaw8|bg7v_Y>d2@(e^9tfrWAVg%xFD7Kc)BD%I$jwkUYNxTi&n;i zF9Q*P0|EgP$Y4zalRy>_we>Xgz&mA6FyH|x$N)m%sA2|2Bq@5t@EQjS!JiNbLEhlF z07b756hQv#AgjDOf^gGM(VzqB2di?C_G2z^YpCgl2jD(X9V+8`xFK-FU7;T~?gu@)L6)P6 zTb>o>PuhQ*!4FSEWk+$Qpp+pLe~RtTBh~HI-3C?Ux&ARf%2?9|^UWFT`Po0`hwKYr z8~(E;wh-HooyAUK=dmML17Kpuu|}*FJ1WP6tLuqkbq#onRm77GbwvO0H}GWTnap#p zb?$clqU_5TPL;o-0cVnAnpBjLna^ayFM^QC74w~jF95ML%OP;$8ZLV#}x4Y<<`fC;bw zws1Z!z#Vu4e-H%1Kop1v$zTGEJ`=8UDVPFgfVrR;l!9eoHK+g^z-F)=>;n5h12_Us zf-~SExD2j?yKwb%f)}6{yaS&R1R)`Ggn<|!ric~dfVdzm#1{!dB9K@l8A(Ml5HTV{ zW+3yBQe*{Efowu*kiEzuVnP{VE8!sFG~7#_gm*+DQIBXt^niOYnV3bKPFze}N8C+3L2M^JCiam? zBmS^jd>KipRHFGsjwHP&# z+8niVwLNO*)E=sRq-oRaX&l-(nvAxDwvBdzcANHwPN!Sb{pl(6eEJf44gD1T0sVtI zL)}T8t1eKVtzM~KuYN`Sr3OXAN`tL2L8DNkOk=;sC5;|Uil((DN0X;HOS4k*kmgOz zJ}rjU2(2isT&=}gJG9Pgb!n5dt+j)+1={nqH*25Le$2oa77Px9&zQ&9!Z^+7)FJBF z=x}wibV_x0>9pze>T2t{>L%(=)veS$s@tK5=~?S>^>Xx<>h06Jq4#Ns@euY9;gCf` z>V{ky@?L+azQ4XuzeInx{&oFN226t>gKUH41`P)H4RJ$z!x+OUhU*Pa8}xgjdT(uGon*bhy1}~3M&E{OQ)p9bbI+D;>t{RJw%YcZ z9np?umuf zw&xMgk6!Lx`Cj|H{_=M8&i3Bn-8IT)lyKCRQICDheRw{bd^&uYzNx+&eLMWj{CIxV zevkYu{0077{hzVz*jenI?3V$~0n&hj0q;3poSB>xfmmQjU`b$Gkakc)P({##VDsRN z;M(Bc5Vw%&Atyo!q2Zy+LvM18xqNO7w>QiqY-U(XI4wLbydwNjgnfiGqG>cbI&AdH z(f1;)BE^vnQ6P#NwKD2{v`zHn=)*C@nCO_wn5VHLVrRsjjbp@3jN1|SE_Ja-Jg?`vpbiZo0@w_ ztS!zKw@fmbG<8zD#6eOl>6q*_dG+L8X}EN29+EdcuQ6W-{>Zn=Y-PX59v1i(R2F=g zk~n4mRIRDvsjbuOr!AWHtT4Fnx9RwF{`BSfG$PzZW?dttfgsFKJ%WeB=3Z=0E-=~{IHY~&!iWasl8o6lg zqHiUFk_)BIrDdgG7V{Th_;tju<-hhXNng^o)P3ptWyEFTWjB`lE#JOEYenIT$16v# ztY5`kRkG^s>WQn*m${a0SVLVSThmbo)PWj_tAA zTWUONYIYdySXztL%4)lJj@x;8SKzM3I>)+AyY+UL?g4vbd!Fx2-FstS_`c@-to?Nd ztPWHiWE?E1N9w25_cmlUbTlS6wjT;R)ZFCVbnvj#;cZ9Ej#M7iJ-YN5_1OGl{l^QB z_nnZO=sB5j@^SOT=DVlHoVwN$)6#Z2{Pejq!DmjLWuHBM&ga~b^IqoY8T{(F*@amas+-n!FM_zBgk#OVI&2cw7 zZV7I6-OjoF>Q2F(&v)nCBit*wuX(@hfzgBNKkWY4-QnJG^kLw`)<2m2h(jDA=`T5xAoi8LWKKB&=sr6^oOWT(R{__8;wRcQ!=PT)} z{@07&7`)l`*7a?3Uv%Gtce(FAzc2Y<@L~H$*2lA-l0H5AJmm}NOZiv3uT9^=zuoK4 z?eCZHq&&s100t|Xo({n4Z2-`d0bs!M8Fj4k{HG|0^31A0;6GvI8BtN(0jPuj?=d~_ z@8%`|d$z#i7u44n2S79(fOt=UjvP5y2hM7WbEUix;W=Pq)Yr7!H1KbV^33@|+&{NK zuIdVYZvO=l0(@2T=%Kg(001~;SV?A0O#mtY000O80f%V-1ONa40RR918UO$Q0007@ z0ssU6000310007@0{{d700031000620002a(4LS0000w7L_t&t9b=T2m;cWI1WX_T Z1OOBG0bqAaE;0ZB002ovPDHLkV1iKSREhuq literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d577904815105de1161eb680c3343ff9c452feb GIT binary patch literal 3291 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 Z1OOBG0bqAaE;0ZB002ovPDHLkV1iKSREhuq literal 0 HcmV?d00001 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 + }) +}