1+ use std:: borrow:: Cow ;
12use std:: path:: { Path , PathBuf } ;
23use std:: sync:: OnceLock ;
34
4- use axum:: body:: Body ;
5+ use axum:: body:: { Body , Bytes } ;
56use axum:: http:: { HeaderMap , HeaderValue , StatusCode , header} ;
67use axum:: response:: { IntoResponse , Response } ;
78use fabro_static:: EnvVars ;
@@ -101,9 +102,8 @@ async fn load_injected_install_shell(
101102 asset_root : Option < & Path > ,
102103 dev_disk_only : bool ,
103104) -> Option < Vec < u8 > > {
104- Some ( inject_install_mode (
105- load_asset ( "index.html" , asset_root, dev_disk_only) . await ?,
106- ) )
105+ let shell = load_asset ( "index.html" , asset_root, dev_disk_only) . await ?;
106+ Some ( inject_install_mode ( shell. bytes . into ( ) ) )
107107}
108108
109109#[ derive( Clone , Copy , Debug , Eq , PartialEq ) ]
@@ -188,7 +188,39 @@ fn normalize(path: &str) -> String {
188188 }
189189}
190190
191- async fn load_asset ( path : & str , asset_root : Option < & Path > , dev_disk_only : bool ) -> Option < Vec < u8 > > {
191+ /// An asset body plus, when the source precomputed it (the embedded SPA
192+ /// snapshot), its SHA-256. Carrying the hash lets mutable-asset ETags reuse
193+ /// rust-embed's compile-time digest instead of rehashing process-lifetime
194+ /// bytes on every revalidation.
195+ struct Asset {
196+ bytes : Bytes ,
197+ sha256 : Option < [ u8 ; 32 ] > ,
198+ }
199+
200+ impl Asset {
201+ fn from_vec ( bytes : Vec < u8 > ) -> Self {
202+ Self {
203+ bytes : bytes. into ( ) ,
204+ sha256 : None ,
205+ }
206+ }
207+
208+ fn from_embedded ( asset : fabro_spa:: AssetBytes ) -> Self {
209+ let sha256 = asset. sha256 ( ) ;
210+ let bytes = match asset. into_cow ( ) {
211+ // Release builds embed assets as statics; serve them without
212+ // copying the (potentially multi-megabyte) body per request.
213+ Cow :: Borrowed ( bytes) => Bytes :: from_static ( bytes) ,
214+ Cow :: Owned ( bytes) => Bytes :: from ( bytes) ,
215+ } ;
216+ Self {
217+ bytes,
218+ sha256 : Some ( sha256) ,
219+ }
220+ }
221+ }
222+
223+ async fn load_asset ( path : & str , asset_root : Option < & Path > , dev_disk_only : bool ) -> Option < Asset > {
192224 if spa_assets_disabled_for_test ( ) {
193225 return None ;
194226 }
@@ -197,11 +229,11 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
197229 // workspace's live `dist/` fallback or test isolation breaks.
198230 if let Some ( root) = asset_root {
199231 if let Some ( bytes) = read_disk_asset_from_root ( root, path) . await {
200- return Some ( bytes) ;
232+ return Some ( Asset :: from_vec ( bytes) ) ;
201233 }
202234 } else if cfg ! ( debug_assertions) {
203235 if let Some ( bytes) = read_disk_asset ( path) . await {
204- return Some ( bytes) ;
236+ return Some ( Asset :: from_vec ( bytes) ) ;
205237 }
206238 }
207239
@@ -212,17 +244,19 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
212244 return None ;
213245 }
214246
215- fabro_spa:: get ( path) . map ( fabro_spa :: AssetBytes :: into_vec )
247+ fabro_spa:: get ( path) . map ( Asset :: from_embedded )
216248}
217249
218250async fn load_asset_for_mode (
219251 path : & str ,
220252 mode : SpaMode ,
221253 asset_root : Option < & Path > ,
222254 dev_disk_only : bool ,
223- ) -> Option < Vec < u8 > > {
255+ ) -> Option < Asset > {
224256 if mode == SpaMode :: Install && path == "index.html" {
225- return cached_install_mode_shell ( asset_root, dev_disk_only) . await ;
257+ return cached_install_mode_shell ( asset_root, dev_disk_only)
258+ . await
259+ . map ( Asset :: from_vec) ;
226260 }
227261 load_asset ( path, asset_root, dev_disk_only) . await
228262}
@@ -280,13 +314,14 @@ fn disk_asset_root() -> PathBuf {
280314const IMMUTABLE_CACHE_CONTROL : & str = "public, max-age=31536000, immutable" ;
281315const REVALIDATE_CACHE_CONTROL : & str = "no-cache" ;
282316
283- fn asset_response ( path : & str , bytes : Vec < u8 > , request_headers : & HeaderMap ) -> Response {
284- let cache_control = cache_control ( path) ;
317+ fn asset_response ( path : & str , asset : Asset , request_headers : & HeaderMap ) -> Response {
318+ let content_hashed = is_content_hashed ( path) ;
319+ let cache_control = cache_control ( content_hashed) ;
285320 // Mutable assets keep stable names across deploys, so their `no-cache`
286321 // policy needs a validator to revalidate as a cheap 304 instead of a full
287322 // body download on every use. Hashed immutable assets never revalidate,
288- // so hashing their (multi-megabyte) bytes per request would be waste .
289- let etag = ( cache_control == REVALIDATE_CACHE_CONTROL ) . then ( || asset_etag ( & bytes ) ) ;
323+ // so an ETag would be dead weight .
324+ let etag = ( !content_hashed ) . then ( || asset_etag ( & asset ) ) ;
290325
291326 if let Some ( etag) = & etag {
292327 if if_none_match_matches ( request_headers, etag) {
@@ -298,7 +333,7 @@ fn asset_response(path: &str, bytes: Vec<u8>, request_headers: &HeaderMap) -> Re
298333 }
299334
300335 let mime = mime_guess:: from_path ( path) . first_or_octet_stream ( ) ;
301- let mut response = Response :: new ( Body :: from ( bytes) ) ;
336+ let mut response = Response :: new ( Body :: from ( asset . bytes ) ) ;
302337 * response. status_mut ( ) = StatusCode :: OK ;
303338 response. headers_mut ( ) . insert (
304339 header:: CONTENT_TYPE ,
@@ -321,8 +356,11 @@ fn apply_cache_headers(headers: &mut HeaderMap, cache_control: &'static str, eta
321356 }
322357}
323358
324- fn asset_etag ( bytes : & [ u8 ] ) -> String {
325- format ! ( "\" {}\" " , hex:: encode( Sha256 :: digest( bytes) ) )
359+ fn asset_etag ( asset : & Asset ) -> String {
360+ let digest = asset
361+ . sha256
362+ . unwrap_or_else ( || Sha256 :: digest ( & asset. bytes ) . into ( ) ) ;
363+ format ! ( "\" {}\" " , hex:: encode( digest) )
326364}
327365
328366fn if_none_match_matches ( headers : & HeaderMap , etag : & str ) -> bool {
@@ -336,8 +374,8 @@ fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
336374 } )
337375}
338376
339- fn cache_control ( path : & str ) -> & ' static str {
340- if is_content_hashed ( path ) {
377+ fn cache_control ( content_hashed : bool ) -> & ' static str {
378+ if content_hashed {
341379 IMMUTABLE_CACHE_CONTROL
342380 } else {
343381 REVALIDATE_CACHE_CONTROL
@@ -395,8 +433,8 @@ mod tests {
395433 use axum:: http:: { HeaderMap , HeaderValue , StatusCode , header} ;
396434
397435 use super :: {
398- accepts_html, cache_control, inject_install_mode, is_source_map , read_disk_asset_from_root ,
399- serve_with_asset_root,
436+ accepts_html, cache_control, inject_install_mode, is_content_hashed , is_source_map ,
437+ read_disk_asset_from_root , serve_with_asset_root,
400438 } ;
401439
402440 fn headers_with_accept ( value : & str ) -> HeaderMap {
@@ -431,35 +469,36 @@ mod tests {
431469
432470 #[ test]
433471 fn hashed_assets_are_cached_immutably ( ) {
434- assert_eq ! (
435- cache_control( "assets/entry-0sv53bs3.js" ) ,
436- "public, max-age=31536000, immutable"
437- ) ;
438- assert_eq ! (
439- cache_control( "assets/chunk-4tr91ktd.js" ) ,
440- "public, max-age=31536000, immutable"
441- ) ;
442- assert_eq ! (
443- cache_control( "assets/chunk-x912wb67.css" ) ,
444- "public, max-age=31536000, immutable"
445- ) ;
472+ for path in [
473+ "assets/entry-0sv53bs3.js" ,
474+ "assets/chunk-4tr91ktd.js" ,
475+ "assets/chunk-x912wb67.css" ,
476+ ] {
477+ assert ! ( is_content_hashed( path) , "{path} should be content-hashed" ) ;
478+ }
479+ assert_eq ! ( cache_control( true ) , "public, max-age=31536000, immutable" ) ;
446480 }
447481
448482 #[ test]
449483 fn stable_named_assets_must_revalidate ( ) {
450484 // Files whose names do NOT change when their bytes change would be
451485 // pinned stale in browsers for a year if marked immutable.
452- assert_eq ! ( cache_control( "index.html" ) , "no-cache" ) ;
453- assert_eq ! ( cache_control( "assets/app.css" ) , "no-cache" ) ;
454- assert_eq ! (
455- cache_control( "assets/pierre-diffs-worker/worker-portable.js" ) ,
456- "no-cache"
457- ) ;
458- assert_eq ! ( cache_control( "images/apple-touch-icon.png" ) , "no-cache" ) ;
459- // Dash segment that isn't an 8-char lowercase base-36 hash.
460- assert_eq ! ( cache_control( "assets/entry-abc123.js" ) , "no-cache" ) ;
461- // Right hash shape, but not a bundler output extension.
462- assert_eq ! ( cache_control( "assets/photo-a1b2c3d4.png" ) , "no-cache" ) ;
486+ for path in [
487+ "index.html" ,
488+ "assets/app.css" ,
489+ "assets/pierre-diffs-worker/worker-portable.js" ,
490+ "images/apple-touch-icon.png" ,
491+ // Dash segment that isn't an 8-char lowercase base-36 hash.
492+ "assets/entry-abc123.js" ,
493+ // Right hash shape, but not a bundler output extension.
494+ "assets/photo-a1b2c3d4.png" ,
495+ ] {
496+ assert ! (
497+ !is_content_hashed( path) ,
498+ "{path} should not be content-hashed"
499+ ) ;
500+ }
501+ assert_eq ! ( cache_control( false ) , "no-cache" ) ;
463502 }
464503
465504 #[ tokio:: test]
0 commit comments