1- using System . Web ;
1+ using System . Security . Cryptography ;
2+ using System . Text ;
3+ using System . Web ;
24using Deucalion . Api . Options ;
5+ using Microsoft . AspNetCore . StaticFiles ;
6+ using Microsoft . Net . Http . Headers ;
37
48namespace Deucalion . Service ;
59
610internal static class WebApplicationExtensions
711{
812 /// <summary>
9- /// Use file server with response cache for all static files in '/assets'.
13+ /// Serve static files with pre-compressed (Brotli/Gzip) support and immutable caching for '/assets'.
1014 /// </summary>
1115 internal static WebApplication UseCachedFileServer ( this WebApplication app )
1216 {
17+ var contentTypeProvider = new FileExtensionContentTypeProvider ( ) ;
18+ var webRootPath = app . Environment . WebRootPath ;
19+
20+ // Middleware: serve pre-compressed .br/.gz sidecar files for /assets/
21+ app . Use ( async ( context , next ) =>
22+ {
23+ var path = context . Request . Path . Value ;
24+ if ( path ? . StartsWith ( "/assets/" ) == true )
25+ {
26+ var acceptEncoding = context . Request . Headers . AcceptEncoding . ToString ( ) ;
27+ var physicalPath = Path . Combine ( webRootPath , path . TrimStart ( '/' ) . Replace ( '/' , Path . DirectorySeparatorChar ) ) ;
28+
29+ string ? compressedPath = null ;
30+ string ? encoding = null ;
31+
32+ if ( acceptEncoding . Contains ( "br" ) )
33+ {
34+ var brPath = physicalPath + ".br" ;
35+ if ( File . Exists ( brPath ) )
36+ {
37+ compressedPath = brPath ;
38+ encoding = "br" ;
39+ }
40+ }
41+
42+ if ( compressedPath is null && acceptEncoding . Contains ( "gzip" ) )
43+ {
44+ var gzPath = physicalPath + ".gz" ;
45+ if ( File . Exists ( gzPath ) )
46+ {
47+ compressedPath = gzPath ;
48+ encoding = "gzip" ;
49+ }
50+ }
51+
52+ if ( compressedPath is not null )
53+ {
54+ if ( contentTypeProvider . TryGetContentType ( path , out var contentType ) )
55+ {
56+ context . Response . ContentType = contentType ;
57+ }
58+
59+ context . Response . Headers . ContentEncoding = encoding ;
60+ context . Response . Headers . Vary = "Accept-Encoding" ;
61+ context . Response . ContentLength = new FileInfo ( compressedPath ) . Length ;
62+
63+ SetImmutableCacheHeaders ( context . Response ) ;
64+
65+ await context . Response . SendFileAsync ( compressedPath ) ;
66+ return ;
67+ }
68+ }
69+
70+ await next ( ) ;
71+ } ) ;
72+
73+ // Fallback: serve uncompressed files (response compression middleware handles on-the-fly compression)
1374 var fso = new FileServerOptions ( ) ;
1475 fso . StaticFileOptions . OnPrepareResponse = ( context ) =>
1576 {
16- //
1777 if ( context . Context . Request . Path . StartsWithSegments ( "/assets" ) )
1878 {
19- var headers = context . Context . Response . GetTypedHeaders ( ) ;
20- headers . CacheControl = new Microsoft . Net . Http . Headers . CacheControlHeaderValue
21- {
22- Public = true ,
23- MaxAge = TimeSpan . FromDays ( 7 ) ,
24- SharedMaxAge = TimeSpan . FromHours ( 12 )
25- } ;
79+ SetImmutableCacheHeaders ( context . Context . Response ) ;
2680 }
2781 } ;
2882
@@ -34,6 +88,7 @@ internal static WebApplication UseCachedFileServer(this WebApplication app)
3488 /// <summary>
3589 /// Serve 'index.html' replacing SEO elements with values from app configuration.
3690 /// The processed result is cached at startup since PageTitle and PageDescription don't change at runtime.
91+ /// Supports conditional requests via ETag for efficient revalidation.
3792 /// </summary>
3893 internal static WebApplication UseIndexPage ( this WebApplication app )
3994 {
@@ -42,11 +97,27 @@ internal static WebApplication UseIndexPage(this WebApplication app)
4297
4398 if ( cachedContent is not null )
4499 {
100+ // Pre-compute ETag based on content hash
101+ var hashBytes = SHA256 . HashData ( Encoding . UTF8 . GetBytes ( cachedContent ) ) ;
102+ var etag = $ "\" { Convert . ToHexString ( hashBytes [ ..8 ] ) } \" ";
103+
45104 app . Use ( async ( context , next ) =>
46105 {
47106 if ( context . Request . Path == "/" )
48107 {
108+ // Conditional GET: return 304 if client has current version
109+ var ifNoneMatch = context . Request . Headers . IfNoneMatch . ToString ( ) ;
110+ if ( ifNoneMatch == "*" || ifNoneMatch . Contains ( etag ) )
111+ {
112+ context . Response . StatusCode = StatusCodes . Status304NotModified ;
113+ context . Response . Headers . CacheControl = "no-cache" ;
114+ context . Response . Headers . ETag = etag ;
115+ return ;
116+ }
117+
49118 context . Response . ContentType = "text/html" ;
119+ context . Response . Headers . CacheControl = "no-cache" ;
120+ context . Response . Headers . ETag = etag ;
50121 await context . Response . WriteAsync ( cachedContent ) ;
51122 return ;
52123 }
@@ -58,6 +129,17 @@ internal static WebApplication UseIndexPage(this WebApplication app)
58129 return app ;
59130 }
60131
132+ private static void SetImmutableCacheHeaders ( HttpResponse response )
133+ {
134+ var headers = response . GetTypedHeaders ( ) ;
135+ headers . CacheControl = new CacheControlHeaderValue
136+ {
137+ Public = true ,
138+ MaxAge = TimeSpan . FromDays ( 365 ) ,
139+ Extensions = { new NameValueHeaderValue ( "immutable" ) }
140+ } ;
141+ }
142+
61143 private static string ? BuildIndexContent ( WebApplication app )
62144 {
63145 var indexFile = app . Environment . WebRootFileProvider . GetFileInfo ( "/index.html" ) . PhysicalPath ;
0 commit comments