Static Asset'ler ve UI Altyapısı: MapStaticAssets, Fingerprinting, CSS Isolation ve Client Kütüphaneleri
MapStaticAssets dosyaları build sırasında keşfedip gzip/Brotli ile sıkıştırıyor, fingerprint'li URL'lere bir yıllık immutable cache veriyor. Gerçek header'larla ölçüyor, .NET 10 MVC template'inde MapControllerRoute(...).WithStaticAssets()'in neden hiçbir şey yapmadığını ve düzeltmesini, CSS isolation'daki tag helper tuzağını ve LibMan kullanımını gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402, ASP.NET Core runtime 10.0.13), LibMan (Library Manager) CLI (Command-Line Interface) 3.0.114 ve htmx 2.0.11 ile derlenip denenmiştir.
Önce Tahmin Et
dotnet new mvc ile oluşturduğun .NET 10 projesinin _Layout.cshtml'inde şu satır var:
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
Program.cs'te de template'in yazdığı haliyle app.MapStaticAssets() ve MapControllerRoute(...).WithStaticAssets() duruyor. Uygulamayı dotnet publish ile yayınlayıp Production'da çalıştırıyorsun ve /Home/Privacy sayfasının HTML'ine (HyperText Markup Language) bakıyorsun. href ne olur: /css/site.css, /css/site.css?v=... mi, yoksa /css/site.b9sayid5wm.css gibi fingerprint'li bir adres mi? Aynı layout'u kullanan bir Razor Page'de cevap değişir mi? Bu ikinci soru, yazının en ilginç bulgusunu taşıyor.
UseStaticFiles'tan MapStaticAssets'e
.NET 8'e kadar static dosyalar tek bir middleware'le sunuluyordu: app.UseStaticFiles(). Bu middleware her istekte wwwroot'a bakar, dosya varsa ETag ve Last-Modified header'larıyla döner. Sıkıştırma yapmaz, cache süresini sen ayarlamazsan Cache-Control göndermez, dosya adına dokunmaz. Cache busting için layout'taki asp-append-version tag helper attribute'u URL'in (Uniform Resource Locator) sonuna dosyanın hash'ini ?v= olarak ekler.
.NET 9 ile gelen MapStaticAssets farklı bir yerden başlıyor: dosyalar çalışma anında değil build sırasında keşfediliyor. Bookstore'un pipeline'ı şöyle:
app.UseRouting();
// build manifest'inde olmayan dosyalar için (ör. upload'lar)
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// MapControllerRoute(...).WithStaticAssets() hiçbir şey yapmıyor (dotnet/aspnetcore#59152)
app.MapControllers()
.WithStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
Bu kodun iki satırı template'ten farklı; ikisini de aşağıda gerçek çıktılarla açıklayacağım. Önce MapStaticAssets'in build sırasında ne ürettiğine bakalım. dotnet publish -c Release sonrasında çıktı klasöründe Bookstore.Web.staticwebassets.endpoints.json adında bir manifest dosyası oluşuyor ve wwwroot'taki her dosyanın yanında sıkıştırılmış kopyaları duruyor:
-rw-r--r-- 232803 bootstrap.min.css
-rw-r--r-- 22709 bootstrap.min.css.br
-rw-r--r-- 30970 bootstrap.min.css.gz
Manifest'te bizim projede 642 endpoint var. site.css'in fingerprint'li ve Brotli ile sıkıştırılmış hali için kayıt şöyle:
{
"Route": "css/site.b9sayid5wm.css",
"AssetFile": "css/site.css.br",
"Selectors": [
{
"Name": "Content-Encoding",
"Value": "br",
"Quality": "0.004310344828"
}
],
"ResponseHeaders": [
{ "Name": "Cache-Control", "Value": "max-age=31536000, immutable" },
{ "Name": "Content-Encoding", "Value": "br" },
{ "Name": "Content-Length", "Value": "231" },
{ "Name": "Content-Type", "Value": "text/css" },
{ "Name": "ETag", "Value": "\"FVzJe6Q2UlG8wiE0wKPzsNcJ5m9zWTbKIN9rO8SkzT8=\"" },
{ "Name": "Last-Modified", "Value": "Sun, 08 Nov 2026 02:02:28 GMT" },
{ "Name": "Vary", "Value": "Accept-Encoding" }
],
"EndpointProperties": [
{ "Name": "fingerprint", "Value": "b9sayid5wm" },
{ "Name": "integrity", "Value": "sha256-j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg=" },
{ "Name": "label", "Value": "css/site.css" }
]
}
(Okunabilsin diye header satırlarını tek satıra topladım ve bir property'yi çıkardım.) Response header'ları build sırasında hesaplanmış ve dosyaya yazılmış. Çalışma anında MapStaticAssets bu manifest'i okuyup her kayıt için bir endpoint açıyor; istek geldiğinde diske bakıp hash hesaplamıyor, hazır header'larla dosyayı gönderiyor. Microsoft'un dokümanına göre geliştirme sırasında yalnız gzip, publish sırasında hem gzip hem Brotli üretiliyor; zaten sıkıştırılmış olan resim ve font dosyaları bu işe girmiyor.
Header'lar: Fingerprint'li ve Fingerprint'siz Adres
Production'da aynı dosyayı iki farklı adresten istedim:
$ curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, deflate, br' http://localhost:17811/css/site.b9sayid5wm.css
HTTP/1.1 200 OK
Content-Length: 231
Content-Type: text/css
Cache-Control: max-age=31536000, immutable
Content-Encoding: br
ETag: "FVzJe6Q2UlG8wiE0wKPzsNcJ5m9zWTbKIN9rO8SkzT8="
Vary: Accept-Encoding
$ curl -s -D - -o /dev/null http://localhost:17811/css/site.css
HTTP/1.1 200 OK
Content-Length: 667
Content-Type: text/css
Cache-Control: no-cache
ETag: "j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg="
Vary: Accept-Encoding
Fark çok net. Fingerprint'li adres bir yıl (max-age=31536000) ve immutable ile geliyor: tarayıcı bu dosyayı bir daha hiç sormayacak. Bunu güvenle söyleyebiliyor çünkü dosyanın içeriği değişirse fingerprint de, dolayısıyla URL de değişecek. Fingerprint'siz adres ise no-cache ile geliyor. no-cache "cache'leme" demek değil, "her kullanımda sunucuya sor" demek. Tarayıcı elindeki kopyanın ETag'ini If-None-Match ile gönderiyor:
$ curl -s -D - -o /dev/null -H 'If-None-Match: "j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg="' http://localhost:17811/css/site.css
HTTP/1.1 304 Not Modified
Content-Type: text/css
304 gövdesiz bir cevap, ama yine de bir istek ve bir round trip. Sayfada 15 asset varsa her sayfa açılışında 15 tane 304 demek. Fingerprint'li adreste bu istekler hiç oluşmuyor.
Sıkıştırmanın etkisini Bootstrap'in CSS (Cascading Style Sheets) dosyasında ölçtüm:
identity 232803
gzip 30970
br 22709
Brotli ile dosya yaklaşık onda bire iniyor ve bunun maliyeti çalışma anında sıfır, çünkü sıkıştırma build sırasında bir kez yapıldı. Eski düzende aynı kazancı almak için AddResponseCompression ile her istekte CPU (Central Processing Unit) harcıyordun ya da işi reverse proxy'ye bırakıyordun.
Development ortamında tablo farklı. Fingerprint'li adres bile Cache-Control: no-cache ile geliyor; framework, Hot Reload sırasında eski dosyaların görünmesin diye cache header'larını bilerek eziyor. Bu davranışı appsettings.Development.json'da EnableStaticAssetsDevelopmentCaching: true ile kapatabilirsin. Yerelde ölçüm yaparken bunu bilmezsen "immutable nerede?" diye boşuna arıyorsun.
Tag Helper'lar, Import Map ve Template'teki Sessiz Bug
Fingerprint'li URL'i view'a yazan kim? WithStaticAssets(), ilgili endpoint'lerin metadata'sına bir ResourceAssetCollection ekliyor. Link ve script tag helper'ları ~/ ile başlayan bir adresi render ederken bu koleksiyona bakıp fingerprint'li karşılığını yazıyor. Layout'un <head>'indeki boş <script type="importmap"></script> de aynı koleksiyondan bir import map üretiyor; JavaScript module'leri import "./js/site.js" yazdığında tarayıcı fingerprint'li dosyayı yüklüyor.
Şimdi baştaki sorunun cevabı. Template'in kendi Program.cs'iyle, Production'da, aynı layout'u kullanan iki sayfanın gerçek HTML'i:
<!-- Razor Page: /Account/Login -->
<script type="importmap">{
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.46ein0sx1k.css" />
<link rel="stylesheet" href="/css/site.b9sayid5wm.css" />
<link rel="stylesheet" href="/Bookstore.Web.vhlbjall1w.styles.css" />
<!-- MVC view: /Home/Privacy -->
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/site.css?v=j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg" />
<link rel="stylesheet" href="/Bookstore.Web.styles.css?v=bdNtA_QoaAbCJducQYa0ZwEA7ziL7VseAqxTBL0_NzA" />
Razor Page fingerprint'li adresleri ve import map'i alıyor. MVC (Model-View-Controller) view'ı ise import map'i hiç render etmiyor ve eski ?v= davranışına düşüyor. Bootstrap'in satırında asp-append-version olmadığı için orada hiçbir versiyon bilgisi yok. Bunu tamamen boş bir dotnet new mvc projesinde de denedim, sonuç aynı. Endpoint metadata'sını listeleyince sebep görünüyor: controller endpoint'lerinde ResourceAssetCollection yok.
Sebep ASP.NET Core'un kaynak kodunda. WithStaticAssets(), kendisine verilen builder'ın Items sözlüğünde __EndpointRouteBuilder anahtarını arıyor ve bulamazsa sessizce hiçbir şey yapmıyor. MapControllers() bu anahtarı içeren builder'ı dönüyor, ama MapControllerRoute() ve MapDefaultControllerRoute() route'a özel yeni bir builder dönüyor ve o builder'da anahtar yok. GitHub'da bu durum dotnet/aspnetcore #59003 ve template için açılan #59152 olarak kayıtlı. #59152 bu yazıyı yazdığım tarihte açık ve ".NET 12 Planning" milestone'unda; düzeltme için açılmış PR (Pull Request) #65188 de henüz merge edilmemiş.
Çözüm yukarıdaki pipeline'daki satır: app.MapControllers().WithStaticAssets(). MapControllers()'ın döndüğü varsayılan builder'a eklenen convention bütün controller action'larına uygulanıyor; conventional route'larla gelenlere de. Bu satırla /Home/Privacy da artık aynı çıktıyı veriyor:
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.46ein0sx1k.css" />
<link rel="stylesheet" href="/css/site.b9sayid5wm.css" />
<link rel="stylesheet" href="/Bookstore.Web.vhlbjall1w.styles.css" />
<script src="/lib/jquery/dist/jquery.min.o1o13a6vjx.js"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.bundle.min.493y06b0oq.js"></script>
<script src="/js/site.xtxxf3hu2r.js"></script>
Dikkat et, bootstrap.min.css satırında asp-append-version yok ama adres yine fingerprint'li. Koleksiyon devredeyken tag helper, ~/ ile yazılmış ve manifest'te karşılığı olan her adresi çeviriyor. API (Application Programming Interface) controller'ları ([ApiController]) bu metadata'yı almıyor; kaynak kod bunları bilerek atlıyor.
CSS Isolation: Sayfaya Özel Stil
Bir sayfaya özel CSS'i site.css'e yığmak yerine sayfanın yanına aynı adla .css dosyası koyabilirsin. Razor Pages'in yönetim listesi için Pages/Manage/Books/Index.cshtml.css:
h1 {
font-size: 1.5rem;
}
td form {
display: inline;
}
Build sırasında Razor SDK (Software Development Kit) her elemana sayfaya özel bir attribute ekliyor ve selector'ları bu attribute'la daraltıyor. Bütün scoped dosyalar tek bir bundle'da toplanıyor (Bookstore.Web.styles.css, layout bu dosyayı zaten referans ediyor):
/* /Pages/Manage/Books/Index.cshtml.rz.scp.css */
h1[b-ifdkusweri] {
font-size: 1.5rem;
}
td form[b-ifdkusweri] {
display: inline;
}
Render edilen HTML'e baktığımda h1 ve td attribute'u almış, ama silme butonunun form'u almamış:
<h1 b-ifdkusweri>Manage books</h1>
<td b-ifdkusweri>The Pragmatic Path</td>
<form method="post" action="/Manage/Books?id=1&handler=Delete">
Sebep: asp-page-handler, asp-for, asp-page gibi attribute'lar taşıyan elemanlar tag helper tarafından üretiliyor ve scope attribute'u bunlara eklenmiyor. td form[b-ifdkusweri] selector'ı hiçbir şeyle eşleşmiyor, stil sessizce uygulanmıyor. Çözüm ::deep:
td ::deep form {
display: inline;
}
Bu satır td[b-ifdkusweri] form olarak derleniyor: attribute'u olan td'nin içindeki her form. Aynı yöntem partial view'ların ve view component'lerin ürettiği HTML için de gerekiyor; o elemanlar başka bir .cshtml dosyasından geldiği için sayfanın scope attribute'unu almıyor.
Client Library'leri: Template, LibMan ve npm
dotnet new mvc Bootstrap, jQuery, jQuery Validation ve jQuery Validation Unobtrusive'i wwwroot/lib altına kopyalanmış dosyalar olarak getiriyor. Hangi sürüm olduğunu bilen bir paket yöneticisi yok; dosyaların başındaki yorum satırları tek kayıt. .NET 10 template'inde Bootstrap 5.3.3, jQuery 3.7.1, jQuery Validation 1.21.0 geliyor.
Yeni bir library eklerken iki makul yol var. Birincisi LibMan: CDN'den (Content Delivery Network) belirli dosyaları indirip wwwroot'a koyan, Microsoft'un küçük aracı. Global tool kurmana gerek yok, projeye local tool olarak ekliyorsun:
dotnet new tool-manifest
dotnet tool install Microsoft.Web.LibraryManager.Cli
dotnet libman init --default-provider jsdelivr --default-destination wwwroot/lib
dotnet libman install htmx.org@2.0.11 --files dist/htmx.min.js --destination wwwroot/lib/htmx
Ortaya çıkan libman.json repoya giriyor, indirilen dosyalar istersen .gitignore'a:
{
"version": "3.0",
"defaultProvider": "jsdelivr",
"defaultDestination": "wwwroot/lib",
"libraries": [
{
"library": "htmx.org@2.0.11",
"destination": "wwwroot/lib/htmx",
"files": [
"dist/htmx.min.js"
]
}
]
}
Başka bir makinede ya da CI'da (Continuous Integration) dotnet tool restore ve dotnet libman restore yeterli:
Restoring library htmx.org@2.0.11...
wwwroot/lib/htmx/dist/htmx.min.js written to disk
1 libraries restored in 0.05 seconds
İkinci yol npm. Projede zaten bir frontend build adımı varsa (TypeScript derlemesi, Sass, bundler) library'yi package.json'a ekleyip build çıktısını wwwroot altına yazdırmak daha tutarlı. Burada önemli kural şu: MapStaticAssets yalnız build sırasında wwwroot altında bulunan dosyaları manifest'e alıyor. Bundler'ın çıktısını wwwroot dışında bir yere yazıyorsan Microsoft'un dokümanındaki gibi <Content> item'ı ve Link ile wwwroot'a bağlaman gerekiyor.
Bundling'e gelince: eski ASP.NET'teki BundleConfig'in bir karşılığı ASP.NET Core'da yok. HTTP (Hypertext Transfer Protocol) 2'nin tek bağlantı üzerinden paralel istekleri ve fingerprint'li, sıkıştırılmış, immutable asset'lerle küçük bir yönetim panelinde birkaç ayrı dosya sorun değil. Microsoft'un dokümanı yaklaşık 1.000 ve üzeri asset'te bir bundler kullanmayı ya da MapStaticAssets'i UseStaticFiles ile birlikte kullanmayı öneriyor, çünkü manifest'teki metadata uygulama açılırken belleğe yükleniyor. Bundler seçimi ilerideki Build Araçları serisinin konusu.
Üretimde Dikkat
CDN ve reverse proxy önünde fingerprint'li asset'ler en rahat içerik: immutable olduğu için CDN bir kez çeker ve bir yıl tutar, deploy sonrası purge gerekmez çünkü yeni sürüm yeni URL demek. HTML sayfasının kendisi ise kısa ömürlü olmalı; eski HTML eski fingerprint'lere işaret eder. Cache stratejisinin geri kalanı ilerideki Caching serisinde.
Çalışma anında wwwroot'a yazılan dosyalar manifest'te yok. Published uygulamada wwwroot/uploads/cover-42.txt dosyasını elle oluşturup istediğimde 404 aldım. Pipeline'a UseRouting()'den sonra UseStaticFiles() ekleyince aynı dosya 200 döndü; site.css ise hâlâ MapStaticAssets'ten, Brotli ile geliyor. Static file middleware, routing'in eşleştirdiği bir endpoint varsa isteğe dokunmuyor, iki mekanizma çakışmadan yan yana çalışıyor. Yine de kullanıcı upload'larını wwwroot dışında tutup yetki kontrollü bir endpoint'ten sunmak daha doğru; bunun detayı Web API serisinin dosya yükleme yazısında.
Middleware maliyetini kısaltmak istersen app.MapStaticAssets().ShortCircuit() ile asset endpoint'i eşleştiği anda yanıt dönülür; authentication, authorization ve sonraki middleware'ler çalışmaz. Static asset'lerin hepsi anonim olduğu sürece bu güvenli bir kazanç.
Sık Düşülen Tuzaklar
Fingerprint'siz dosyaya uzun max-age vermek. UseStaticFiles'ın OnPrepareResponse'unda /css/site.css için public, max-age=31536000 yazarsan deploy sonrası kullanıcılar bir yıl boyunca eski CSS'i görür; tarayıcı sunucuya hiç sormaz. Uzun süre yalnız URL'i içerikle birlikte değişen dosyalara verilir.
Template'teki MapControllerRoute(...).WithStaticAssets() satırına güvenmek. Yukarıda gördüğün gibi MVC view'larında hiçbir şey yapmıyor, hata da vermiyor. MapControllers().WithStaticAssets() ekle ve Production çıktısında fingerprint'li adresleri gözünle doğrula.
Scoped CSS'in tag helper elemanlarına uygulanacağını sanmak. asp-* attribute'lu form, input ve link'ler scope attribute'u almıyor; ::deep kullan.
Fingerprint'li dosya adını elle yazmak. site.b9sayid5wm.css'i bir e-posta template'ine ya da JavaScript'e sabit olarak koyarsan bir sonraki CSS değişikliğinde 404 alırsın. Adresi her zaman ~/css/site.css olarak yaz, çeviriyi tag helper'a bırak.
Development'ta ölçüm yapmak. Development'ta header'lar no-cache'e eziliyor; cache davranışını publish edilmiş çıktıda ölç.
Küçük Bir Deneme
★☆☆ Kendi projende dotnet publish -c Release çalıştır ve *.staticwebassets.endpoints.json dosyasında kaç endpoint olduğunu say. site.css için kaç ayrı kayıt var ve neden?
★★☆ Program.cs'teki MapControllers().WithStaticAssets() satırını kaldır, uygulamayı Production'da çalıştır ve bir MVC view'ı ile bir Razor Page'in <head>'ini karşılaştır. Sonra wwwroot/js/module.js adında bir JavaScript module'ü oluştur ve view'dan import "./js/module.js" ile yükle. Import map yokken tarayıcının Network sekmesinde hangi URL istenir?
★★★ Bir CDN senaryosunu yerelde taklit et: uygulamanın önüne başka bir portta YARP (Yet Another Reverse Proxy) ya da nginx koy ve Cache-Control header'larının proxy'den geçtikten sonra korunduğunu doğrula. no-cache alan site.css için proxy'nin If-None-Match isteğini sunucuya iletip iletmediğine bak.
Bu yazıda tarayıcıya giden dosyaların yolunu ve ömrünü ayarladık. Sırada ekrandaki metinler var: aynı katalog Türkçe ve İngilizce nasıl sunulur, kültür nereden seçilir ve Türkçe kültürde "i" harfi neden bir login'i bozabilir? Bir sonraki yazıda MVC ve Razor Pages'te localization'a geçiyoruz.