Mert Özen Her Satırda Daha İleri
Frontend Build Araçları — #07

Source Map'ler: ECMA-426 Formatı, Production'da Hidden Source Map, Error Tracking ve Güvenlik

Mert Özen 14 Kas 2026 11 dk 15 görüntülenme
Source Map'ler: ECMA-426 Formatı, Production'da Hidden Source Map, Error Tracking ve Güvenlik

Source map, minify edilmiş koddaki her konumu kaynak koda eşleyen ve Aralık 2024'ten beri ECMA-426 ile standart olan bir JSON dosyasıdır; hidden source map yalnız sourceMappingURL yorumunu kaldırır, dosya sunucudaysa herkes indirebilir. Bookstore'da mappings alanını Base64 VLQ ile çözüyor, ASP.NET Core'un .map dosyasını sunduğunu görüyor, map'leri deploy'dan ayırıyor ve production hatasını debug ID ile TypeScript satırına eşliyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular CLI ve @angular/build 22.2.7) ve TypeScript 6.0, Vite 8.3 (Rolldown 1.2) ve TypeScript 7.0, .NET 10 (ASP.NET Core 10) ve Node.js 24 ile denenmiştir; tarayıcı davranışı Playwright 1.65'in Chromium'uyla gözlenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) iki frontend'inde de production için source map açtık, ama "gizli" olarak. Angular istemcisi bookstore-web'de:

"production": {
  "outputHashing": "all",
  "sourceMap": {
    "scripts": true,
    "styles": false,
    "hidden": true
  }
}

Bookstore.Web'e gömülen Vite widget'ında da aynısı:

build: {
    manifest: true,
    outDir: "../server/Bookstore.Web/wwwroot/dist",
    // sourceMappingURL yorumu olmayan .map dosyaları; JS ve map aynı debugId'yi taşır
    sourcemap: "hidden",
    rolldownOptions: { output: { sourcemapDebugIds: true } },
},

npm run build:server sonrasında wwwroot/dist/assets altında main-DH5dtSTh.js ve main-DH5dtSTh.js.map var; JavaScript dosyasının sonunda artık sourceMappingURL yorumu yok. Uygulamayı yayına aldık. Siteyi ziyaret eden biri /dist/assets/main-DH5dtSTh.js.map adresini tarayıcıya yazarsa ne olur?

Bu yazı Build Araçları serisinin yedinci yazısı. Bir önceki yazıda bundle analizi için source map'ten yararlanmıştık; şimdi source map'in kendisine bakıyoruz: dosyanın yapısı, mappings alanının nasıl kodlandığı, production'da source map stratejileri, error tracking servisleriyle eşleştirme ve source map'in kaynak kodunu sızdırma riski. Source map'i DevTools'ta debugging için kullanmayı Browser DevTools serisinin "Sources Paneli ile JavaScript Debugging: Breakpoint Türleri, Source Map ve Ignore List" yazısında anlattık; burada build ve production tarafındayız.

Source Map'in Yapısı

Source map, minify edilmiş bir JavaScript dosyasındaki her konumun kaynak koddaki karşılığını tutan bir JSON dosyası. Formatın ilk sürümü Google'ın Closure Inspector aracı için yazıldı; bugün kullanılan 3. sürüm de yıllarca resmî bir standart olmadan, araçların ortak kabulüyle yaşadı. Ecma International onu Aralık 2024'te ilk kez standart olarak yayımladı (ECMA-426, "Source map format"). Bugün standardın taslağı ve yeni öneriler TC39'un (JavaScript standardını yöneten komite) bir çalışma grubunda geliştiriliyor.

Angular'ın orders-page lazy chunk'ı için ürettiği map (sourcesContent kısaltıldı):

{
  "version": 3,
  "file": "chunk-wi8gYIFU.js",
  "names": [
    "ɵɵdomElementStart",
    "ɵɵtext",
    "...",
    "formatLongDate",
    "..."
  ],
  "sources": [
    "../src/app/orders/orders-page.ts"
  ],
  "sourcesContent": [
    "..."
  ],
  "mappings": "A,+GAcMA,IAAAA,GAAAA,EAAAA,IAAAA,EAAAA,GAAAA,...",
  "debugId": "101e1479-3a3f-50a0-963d-dc448ae853e3"
}

Alanlar:

  • version her zaman 3; file map'in ait olduğu dosya.
  • sources kaynak dosyaların yolları; sourcesContent bu dosyaların içeriğinin tamamı, yorumlar dahil.
  • names minify sırasında adı değişen identifier'ların orijinal adları: yukarıda formatLongDate, çıktıda u oldu.
  • mappings asıl eşleme; aşağıda açıyoruz.
  • ignoreList, sources içinde DevTools'un ve stack trace'lerin varsayılan olarak gizleyeceği dosyaların index'leri. Angular'ın main map'inde 107 kaynağın 87'si node_modules'tan ve bu listede tam olarak o 87 dosya var.
  • debugId standardın kendisinde yok; TC39'da Stage 2'deki Debug ID önerisinden geliyor ve aşağıda error tracking'de işimize yarayacak.

mappings Alanı ve Base64 VLQ

mappings, üretilen dosyanın satırlarını ; ile, her satırdaki segment'leri , ile ayırıyor. Her segment 1, 4 ya da 5 sayı taşıyor: üretilen dosyadaki sütun, sources index'i, kaynak satırı, kaynak sütunu ve varsa names index'i. Dosyayı küçük tutmak için iki hile var. Birincisi, sayılar mutlak değil, bir önceki segment'e göre fark olarak yazılıyor; kaynakta yan yana duran kodun farkları küçük sayılar oluyor. İkincisi, her sayı Base64 VLQ (Variable-Length Quantity; küçük sayıyı az karakterle yazan değişken uzunluklu kodlama) ile kodlanıyor: her Base64 karakteri 6 bit taşıyor, bunun 5'i veri, biri "devamı var" işareti; ilk karakterin en düşük biti de sayının işareti. Kavramı görmek için çözümü elle yapan kısa bir script yazdık:

const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

function decodeVlq(segment) {
  const values = [];
  let value = 0;
  let shift = 0;
  for (const char of segment) {
    const digit = BASE64.indexOf(char);
    value += (digit & 31) << shift;
    if (digit & 32) {
      shift += 5;
      continue;
    }
    values.push(value & 1 ? -(value >> 1) : value >> 1);
    value = 0;
    shift = 0;
  }
  return values;
}
node scripts/decode-mappings.mjs dist/bookstore-web/source-maps/chunk-wi8gYIFU.js.map 500

Çıktının placed() method'una düşen kısmı:

CAC3C    [1,0,1,-43]          -> js 1:421  src/app/orders/orders-page.ts:26:5
OAAOC    [7,0,0,7,1]          -> js 1:428  src/app/orders/orders-page.ts:26:12 name=formatLongDate
EAAeD    [2,0,0,15,-1]        -> js 1:430  src/app/orders/orders-page.ts:26:27 name=order
EAAM     [2,0,0,6]            -> js 1:432  src/app/orders/orders-page.ts:26:33

Minify edilmiş dosyada bu kısım placed(t){return u(t.placedAt)}. 421. sütundaki return, kaynakta 26. satırın 5. sütunu; 428. sütundaki u ise 26. satırın 12. sütunundaki formatLongDate. Köşeli parantezdeki sayıların farklar olduğuna dikkat: [7,0,0,7,1], "bir önceki segment'ten 7 sütun sağda, aynı dosya, aynı satır, 7 sütun sağda, bir sonraki name" demek. Bu kodlamayla Angular'ın 224 kB'lık main dosyasının mappings alanı 316 kB tutuyor.

Production'da Üç Strateji

Production'da source map için üç seçenek var:

  • Hiç üretmemek: Angular'ın production varsayılanı. Kullanıcıdan gelen hata at u (main-2CA6OFEQ.js:4:194805) gibi bir satırdır ve okunması çok zordur.
  • Herkese açık: ng build --source-map. JavaScript'in sonunda //# sourceMappingURL=main-LFF7S2KX.js.map yorumu var; DevTools map'i kendiliğinden indiriyor.
  • Hidden: map üretiliyor ama JavaScript'te sourceMappingURL yorumu yok. Map'ler error tracking servisine gidiyor, kullanıcıya gitmiyor.

Herkese açık bir map'in neyi açığa çıkardığına baktık. ng build --source-map çıktısını statik bir sunucuyla yayınlayıp map'i istedik:

public build: GET /main-LFF7S2KX.js.map -> 200 1977155 bytes

224 kB'lık bir JavaScript dosyası için 1.98 MB'lık bir map. Bunun 1.51 MB'ı sourcesContent: Angular'ın, RxJS'in ve bizim TypeScript kaynak kodumuz, yorumlarıyla birlikte. İndirilen dosyada bir önceki yazıda collections.ts'e yazdığımız yorumu aradık ve bulduk: Object.groupBy is Baseline only since March 2024 (Safari 17.4), .... Bookstore'da bu zararsız; ama kod yorumlarında iç sistemlerin adları, eski bir API adresi ya da "bu kontrolü geçici olarak kapattık" gibi notlar varsa, herkese açık map bunları herkese açar. Source map'in kendisi bir güvenlik açığı değil; tarayıcıya giden JavaScript zaten okunabilir. Ama sourcesContent minify edilmemiş, yorumlu kaynak kodun tamamını taşıyor.

Angular'da sourcesContent: false ile içerik map'e yazılmıyor; aynı build'de main map'i 1,977,155 byte'tan 415,618 byte'a indi ve yorum artık map'te yoktu. Bunun bedeli, error tracking servisinin hata satırının çevresindeki kodu gösterememesi. Map'leri kullanıcıya hiç vermiyorsan sourcesContent'i açık bırakmak daha kullanışlı. vendor: true ise node_modules'taki paketlerin kendi source map'lerini de izleyerek map'i bir adım daha kaynağa götürüyor; varsayılanı false.

Hidden Source Map ve Deploy

Tahminin cevabı: map'i ASP.NET Core sunuyor. Bookstore.Web'i vite-manifest profiliyle başlatıp iki istek attık:

GET /dist/assets/main-DH5dtSTh.js -> 200 text/javascript
GET /dist/assets/main-DH5dtSTh.js.map -> 200 text/plain 25554

"Hidden" yalnız JavaScript'teki yorumu kaldırıyor; dosyanın kendisi wwwroot'ta durduğu sürece static files middleware'i onu herkese veriyor. Dosya adı da sır değil: JavaScript'in adına .map eklemek yetiyor. Hidden source map'in anlamı, map'lerin deploy edilen klasörden çıkarılmasıyla tamamlanıyor. Widget'ta bunu build:server script'ine ekledik:

// "vite build --mode server"dan sonra çalışır: wwwroot statik dosya olarak sunuluyor ve ASP.NET Core
// .map dosyalarını da sunuyor; bu yüzden hidden source map'ler publish'ten önce oradan taşınır
import { mkdirSync, readdirSync, readFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';

const assetsDir = '../server/Bookstore.Web/wwwroot/dist/assets';
const mapsDir = 'source-maps';
mkdirSync(mapsDir, { recursive: true });

for (const file of readdirSync(assetsDir).filter((name) => name.endsWith('.map'))) {
  const { debugId } = JSON.parse(readFileSync(join(assetsDir, file), 'utf8'));
  renameSync(join(assetsDir, file), join(mapsDir, file));
  console.log(`${file.padEnd(28)} ${debugId}`);
}
"build:server": "vite build --mode server && node scripts/collect-source-maps.mjs"

Angular tarafında aynı işi yapan scripts/collect-source-maps.mjs, map'leri dist/bookstore-web/browser'dan dist/bookstore-web/source-maps'e taşıyor ve npm run build:prod iki adımı birlikte çalıştırıyor:

chunk-Blx69gxB.js.map        cfe2585f-0e0e-5b1f-a535-135cee5d3910
chunk-ClSMPZ-e.js.map        a5f9776f-ff26-5bc2-a12b-16efa767e9f0
chunk-NfCRZQFd.js.map        5d854da3-1af5-54b0-ad95-c07174333e98
chunk-wi8gYIFU.js.map        101e1479-3a3f-50a0-963d-dc448ae853e3
main-2CA6OFEQ.js.map         04ab4fa9-7d79-5999-a5c7-759004c81f7e

Deploy edilen browser klasörüyle aynı sunucuda bu kez GET /main-2CA6OFEQ.js.map 404 döndü. CI'da source-maps klasörü error tracking servisine yükleniyor ve deploy edilen artifact'a hiç girmiyor.

Error Tracking: Debug ID ile Eşleme

Production'da bir hatanın kaynak koddaki yerini bulmak, error tracking servislerinin yaptığı işin özü. Bunu servis kullanmadan, aynı adımlarla kendimiz yaptık. Önce Angular'ın ErrorHandler'ını, bir error tracking SDK'sının yerine geçen küçük bir class ile değiştirdik:

import { ErrorHandler, Injectable } from '@angular/core';

// Bir error tracking SDK'sının yerine: her hata için minify edilmiş stack'i taşıyan tek bir JSON satırı
@Injectable()
export class BookstoreErrorHandler implements ErrorHandler {
  handleError(error: unknown): void {
    const report = {
      message: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined,
      url: location.href,
    };
    console.error(`[bookstore-error] ${JSON.stringify(report)}`);
  }
}
{ provide: ErrorHandler, useClass: BookstoreErrorHandler },

Sonra gerçek hayatta en sık görülen hatalardan birini taklit ettik: API'den bozuk veri gelmesi. Siparişler listesine geçici olarak tarihi 'pending' olan bir sipariş ekleyip npm run build:prod ile build aldık; bu yüzden aşağıdaki satır numaraları snapshot'taki dosyadan bir fazla. Playwright ile "My orders" sayfasını açtığımızda gelen rapor (port 4200 olarak yazıldı, stack kısaltıldı):

{
  "message": "Invalid time value",
  "stack": "RangeError: Invalid time value\n    at u (http://localhost:4200/main-Y5HWTT5K.js:4:194805)\n    at r.placed (http://localhost:4200/chunk-DcrQce8c.js:1:478)\n    at g (http://localhost:4200/chunk-DcrQce8c.js:1:231)\n    at Vl (http://localhost:4200/main-Y5HWTT5K.js:4:48781)\n    ...",
  "url": "http://localhost:4200/orders"
}

Bu stack'ten kaynak koda gitmek için üç adım gerekiyor ve scripts/symbolicate.mjs bunları sırayla yapıyor. Stack'teki her satırın JavaScript dosyasını buluyor, dosyanın sonundaki //# debugId= yorumundan debug ID'yi okuyor, source-maps klasöründe aynı debugId'yi taşıyan map'i seçiyor. Konumu çevirmek için ayrı bir paket gerekmiyor; Node.js'in node:module module'ündeki SourceMap class'ının findOrigin method'u bunu yapıyor:

const mapsByDebugId = new Map();
for (const file of readdirSync(mapsDir).filter((name) => name.endsWith('.js.map'))) {
  const payload = JSON.parse(readFileSync(join(mapsDir, file), 'utf8'));
  mapsByDebugId.set(payload.debugId, new SourceMap(payload));
}

function debugIdOf(jsFile) {
  const match = readFileSync(join(browserDir, jsFile), 'utf8').match(/\/\/# debugId=([0-9a-f-]+)/);
  return match?.[1];
}
node scripts/symbolicate.mjs report.json
Invalid time value
  at formatLongDate (src/app/shared/dates.ts:23:19)
  at r.placed (src/app/orders/orders-page.ts:27:12)
  at templateFn (src/app/orders/orders-page.ts:15:10)
  at executeTemplate (node_modules/@angular/core/fesm2022/_debug_node-chunk.mjs:5529:5)
  at refreshView (node_modules/@angular/core/fesm2022/_debug_node-chunk.mjs:6224:7)

dates.ts'in 23. satırının 19. sütunu longDate.format(new Date(iso)) çağrısındaki format: geçersiz bir Date'i biçimlendirmeye çalışan satır. Bir altında bu fonksiyonu çağıran placed(), onun altında da template'teki {{ placed(order) }} var.

Debug ID neden gerekli? Map'i dosya adıyla eşlemek de çalışır, ama kırılgan: CDN'ler (dosyaları kullanıcıya yakın sunuculardan dağıtan ağlar) adresi değiştirebilir, aynı dosya adı farklı release'lerde farklı içerik taşıyabilir. Debug ID, JavaScript dosyasıyla map'ini adresten ve dosya adından bağımsız bir kimlikle bağlıyor. Angular 22.1'den beri script source map'i açık olan her build bu kimliği kendisi yazıyor; @angular/build'in kaynak kodunda ID, map'in içeriğinin hash'inden (SHA-1) UUID (128 bitlik benzersiz kimlik) biçiminde üretiliyor, yani aynı kod her build'de aynı ID'yi alıyor. Vite'ta aynı şeyi Rolldown'ın sourcemapDebugIds option'ı yapıyor. Sentry gibi servisler aynı kimliği kullanıyor; Sentry'nin dokümanı, kendi bundler plugin'ini kullanmıyorsan sentry-cli sourcemaps inject ile ID'yi yazdırıp sentry-cli sourcemaps upload ile map'leri yüklemeyi anlatıyor. Angular ve Rolldown ID'yi zaten yazdığı için ilk adım bu projelerde iş yapmıyor.

Fonksiyon Adları ve Scopes Önerisi

Çıktıdaki fonksiyon adlarına dikkat. İlk denememizde script her satırın adını kendi konumundan alıyordu ve dates.ts:23 satırı için formatLongDate yerine u yazıyordu. Sebep names alanının anlamı: bir segment'teki ad, o konumdaki identifier'ın adı, yani o satırda çağrılan fonksiyonun adı. Bir frame'in içinde bulunduğu fonksiyonun adını bulmak için bir alttaki frame'in çağrı konumuna bakmak gerekiyor; script'imiz bunu yapıyor ve error tracking servisleri de benzer sezgilerle çalışıyor. Bu yöntem her zaman sonuç vermiyor: ikinci satırda adı bulamadık ve tarayıcının verdiği r.placed kaldı. Kullanıcının asıl gördüğü, kendi yazdığı placed method'u.

Bu boşluğu TC39'da Stage 3'teki Scopes önerisi kapatıyor. Öneri, map'e kaynak koddaki scope'ları (fonksiyonlar, bloklar ve içlerindeki değişkenler) ve bunların üretilen koddaki karşılıklarını ekliyor. Böylece debugger, minify edilmiş ve inline edilmiş bir fonksiyonun orijinal adını, stack'te inline edildiği için görünmeyen frame'leri ve yeniden adlandırılmış değişkenlerin değerlerini gösterebilecek. Önerinin dokümanı yazım anında bir tarayıcı uygulamasından değil, bir prototipten söz ediyor; bugün build araçlarında açabileceğin bir ayar değil. Aynı repo'daki Range Mappings (Stage 2) ve Debug ID (Stage 2) önerileri de standardın bir sonraki sürümlerine aday.

Üretimde Dikkat

Map'leri deploy edilen artifact'tan ayır ve bunu CI'da kontrol et. dist klasöründe .map dosyası kalmışsa build'i durduran tek satırlık bir kontrol, "hidden" ayarının yanlış güven vermesini engeller.

Map'leri release ile birlikte sakla. Bir hata raporu üç hafta sonra gelebilir; o build'in map'i yoksa debug ID'yi bilmek de işe yaramaz. Error tracking servisine yükleme yapıyorsan saklama süresini de kontrol et.

sourceMap.styles'ı production'da genelde kapalı tut. Angular'da global stillerin ve component stillerinin map'leri hata raporlarında işe yaramaz ama artifact'ı büyütür; bizim config'imizde styles: false.

Source map açmak çıktıyı değiştirir. Angular'da debug ID map'in içeriğinden üretildiği için sourcesContent'i kapattığımızda JavaScript dosyalarının hash'i de değişti: dosyanın sonundaki debugId yorumu farklıydı. Source map ayarını değiştirmek, kullanıcıların bütün dosyaları yeniden indirmesi demek.

Sık Düşülen Tuzaklar

Birincisi, hidden: true'yu "map'ler gizli" diye okumak. Yalnız JavaScript'teki yorum kalkar; map dosyası sunucudaysa adresi tahmin edilebilir ve ASP.NET Core dahil çoğu statik dosya sunucusu onu verir.

İkincisi, sourcesContent'in ne taşıdığını unutmak. Yorumlar dahil bütün kaynak kodu taşır; herkese açık bir map, kodun okunabilir hâlini dağıtmak demek.

Üçüncüsü, map'i yanlış build ile eşleştirmek. Dosya adına göre eşleme, aynı adın farklı içerik taşıdığı durumlarda yanlış satırı gösterir. Debug ID'yi kullan ya da en azından map'leri release'e göre ayrı klasörlerde tut.

Dördüncüsü, error tracking'deki fonksiyon adlarına körü körüne güvenmek. Adlar names alanından sezgiyle çıkarılıyor; satır ve sütun güvenilir, ad bazen değil.

Beşincisi, source map'i bir güvenlik önlemi ya da açığı olarak görmek. Minify edilmiş kod da okunabilir; gizli kalması gereken bir şey (bir API anahtarı, bir iş kuralı) tarayıcıya giden JavaScript'te olmamalı. Bunu serinin Vite yazısında VITE_ değişkenleri için de söylemiştik.

Küçük Bir Deneme

★☆☆ snapshot/07/web'de npm run build:prod çalıştır ve dist/bookstore-web/browser'da .map dosyası kalmadığını, source-maps klasöründeki her map'in debugId'sinin ilgili JavaScript dosyasının son satırıyla aynı olduğunu kontrol et.

★★☆ decode-mappings.mjs'i genişlet: verilen bir kaynak dosyası ve satır için, o satıra eşlenen bütün üretilmiş konumları listelesin. catalog-page.ts'teki groupBy çağrısının minify edilmiş kodda nereye düştüğünü bul.

★★★ symbolicate.mjs'e sourcesContent'ten yararlanan bir özellik ekle: her frame için kaynak satırını ve bir üst ve bir alt satırı yazdırsın. Sonra sourcesContent: false ile build alıp aynı raporu çöz; script'in bu durumda düzgün bir mesajla devam ettiğinden emin ol.

Bir sonraki yazıda serinin son yazısına geçiyoruz: Rolldown, Oxc, Rspack, Turbopack ve Lightning CSS ile 2025–2026'nın sürüm haritası ve bu serideki ölçümleri kendi projende nasıl tekrarlayacağın.