npm Derinlemesine: Dependency'ler, SemVer Aralıkları, package-lock, npm ci, Workspace'ler ve Supply Chain Güvenliği
npm, package.json'daki aralığı package-lock.json'a kaydettiği tam sürümle sabitler: lock aralığı karşıladığı sürece npm install yeni sürüme geçmez, npm ci ise lock'tan sapmaya hiç izin vermez. Gerçek çıktılarla ^ ve ~ aralıklarının Express sürümlerinde ne seçtiği, 0.x caret tuzağı, uyumsuz lock'ta duran npm ci, iç içe content-type kopyaları, @bookstore/contracts workspace'i, npm audit, strict-allow-scripts ile install script onayı ve imza doğrulaması.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) ve npm 11.20.0 ile çalıştırılmıştır; örnek bağımlılık express 5.2.1 ve 5.3.0'dır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için npm'i denediğimiz bir laboratuvar klasörü var. package.json'da Express şöyle tanımlı:
"dependencies": {
"express": "^5.2.1"
}
package-lock.json ise Aralık 2025'te kurulan 5.2.1'i kaydetmiş. O günden beri registry'ye 5.3.0 geldi ve ^5.2.1 aralığı onu da kapsıyor. Ekipten biri repo'yu yeni klonlayıp npm install çalıştırıyor. Hangi sürüm kurulur? Başka biri aynı şeyi npm ci ile yapsa sonuç değişir mi?
npm install
npm ls express
added 69 packages, and audited 70 packages in 431ms
found 0 vulnerabilities
npm-lab@1.0.0 /home/user/bookstore-api/labs/04
`-- express@5.2.1
Kurulan sürüm 5.2.1; npm ci da aynısını kurar. Lock dosyası aralığı karşıladığı sürece npm install onu değiştirmez. Yeni sürümü görmek için ayrıca sormak gerekiyor:
npm outdated
Package Current Wanted Latest Location Depended by
express 5.2.1 5.3.0 5.3.0 node_modules/express 04
semver 7.7.3 7.8.6 7.8.6 node_modules/semver 04
"Wanted", aralığın izin verdiği en yeni sürüm; "Current" ise lock'taki. Yükseltme bilinçli bir adım: npm update express lock dosyasını 5.3.0'a taşır, package.json'daki ^5.2.1'e ise dokunmaz. Bu yazıda bu davranışın arkasındaki kuralları, NuGet'ten farklarını, node_modules'un nasıl kurulduğunu, Bookstore API'sine eklediğimiz npm workspace'i ve bağımlılık zincirine karşı alınacak önlemleri anlatıyoruz.
dependencies, devDependencies, peer ve optional: NuGet'ten Farkı
.NET'te bağımlılıklar .csproj'taki PackageReference satırlarıdır; versiyon yazdığında NuGet bunu "en az bu sürüm" olarak yorumlar ve grafikteki en düşük uygun sürümü seçer. npm'de varsayılan davranış tersine yakın: aralık yazarsın ve npm aralıktaki en yeni sürümü seçer, sonra bunu lock dosyasına kaydeder. package.json'da dört bağımlılık türü var:
| Alan | Ne zaman | Bookstore'dan örnek |
|---|---|---|
dependencies | Uygulama çalışırken gereken paketler | Express, mssql, pino (ilerideki yazılarda) |
devDependencies | Yalnız geliştirme, test ve build sırasında gerekenler | TypeScript, @types/node, semver |
peerDependencies | Paketin kendisi değil, onu kullanan uygulamanın kurması gereken paket | Bir Express plugin'inin Express'e ihtiyacı |
optionalDependencies | Kurulamazsa hata vermeyen paketler, çoğunlukla platforma özel binary'ler | TypeScript 7'nin işletim sistemine göre seçilen native paketleri |
npm 7'den beri peer dependency'ler varsayılan olarak otomatik kurulur ve uyuşmazlık kurulumu durdurur. Bunu aşmak için gördüğün --legacy-peer-deps bayrağı sorunu çözmez, yalnız görünmez yapar. devDependencies ayrımı ise production için önemli; npm ci --omit=dev onları hiç kurmaz:
npm ci --omit=dev
added 68 packages, and audited 69 packages in 516ms
found 0 vulnerabilities
Aynı klasörde düz npm ci 69 paket kuruyor; aradaki tek fark devDependency olan semver.
SemVer Aralıkları: ^, ~ ve 0.x Tuzağı
npm paketleri SemVer (Semantic Versioning; sürümü major.minor.patch biçiminde yazan ve breaking change'i major numarayla bildiren kural) kullanır. Aralıkları ezberlemek yerine registry'deki gerçek Express sürümleri üzerinde deneyelim. Script semver paketini kullanıyor, npm'in kendisinin de kullandığı library:
// Her aralık yayınlanmış sürümlerden hangilerini kabul ediyor? Liste registry'den geliyor
import semver from "semver";
const res = await fetch("https://registry.npmjs.org/express");
const { versions } = await res.json();
const stable = Object.keys(versions).filter((v) => !semver.prerelease(v) && semver.gte(v, "4.21.0"));
for (const range of ["5.2.1", "~5.2.0", "^5.2.0", "^4.21.0", ">=5.0.0 <5.2.0"]) {
const matches = stable.filter((v) => semver.satisfies(v, range));
console.log(range.padEnd(16), "→", matches.join(", ") || "(none)");
}
// 0.x'te caret: yalnız patch değişebilir
console.log("^0.7.1 accepts 0.7.2:", semver.satisfies("0.7.2", "^0.7.1"));
console.log("^0.7.1 accepts 0.8.0:", semver.satisfies("0.8.0", "^0.7.1"));
node labs/04/ranges.js
5.2.1 → 5.2.1
~5.2.0 → 5.2.0, 5.2.1
^5.2.0 → 5.2.0, 5.2.1, 5.3.0
^4.21.0 → 4.21.0, 4.21.1, 4.21.2, 4.22.0, 4.22.1, 4.22.2, 4.22.3
>=5.0.0 <5.2.0 → 5.0.0, 5.0.1, 5.1.0
^0.7.1 accepts 0.7.2: true
^0.7.1 accepts 0.8.0: false
Caret (^) major numarayı sabitler, minor ve patch'i serbest bırakır; tilde (~) yalnız patch'i serbest bırakır. npm install express varsayılan olarak caret yazar. Son iki satır ise sık gözden kaçan kural: major sıfırken caret minor'ı da sabitler, çünkü SemVer'e göre 0.x'te her minor sürüm breaking change içerebilir. Express 5.3.0'ın kendi bağımlılıklarında cookie: '^0.7.1' gibi bir satır var; bu, cookie'nin 0.8'e geçmeyeceği anlamına geliyor.
package-lock.json ve npm ci
Aralıklar, aynı package.json'un farklı günlerde farklı ağaçlar üretmesine izin verir. package-lock.json bunu durdurur: kurulan her paketin tam sürümünü, nereden indirildiğini ve içeriğinin hash'ini (integrity) kaydeder. NuGet'te aynı işi packages.lock.json yapar, ama orada isteğe bağlıdır; npm'de varsayılan olarak üretilir ve commit'lenmelidir.
npm install lock'u okur, package.json ile uyumluysa ona göre kurar, uyumsuzsa ağacı yeniden çözüp lock'u günceller. npm ci ise lock'u değiştirmeyi hiç denemez: node_modules'u silip lock'taki ağacı birebir kurar, uyumsuzluk varsa durur. CI ve image build'leri için doğru komut bu. Uyumsuzluğu görmek için laboratuvarın bir kopyasında package.json'daki aralığı elle ^5.3.0 yapıp lock'u güncellemeden npm ci çalıştırdım (çıktının başı):
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Invalid: lock file's express@5.2.1 does not satisfy express@5.3.0
npm error Invalid: lock file's content-disposition@1.1.0 does not satisfy content-disposition@2.0.1
npm error Invalid: lock file's content-type@1.0.5 does not satisfy content-type@2.1.0
İyi bir sonuç: CI, geliştiricinin unuttuğu bir lock güncellemesini sessizce yapmak yerine durdu. Lock dosyasında merge conflict çıktığında da dosyayı silip yeniden üretmek yerine conflict'i çöz ya da npm install'ın lock'u package.json'a göre düzeltmesine izin ver; silmek bütün ağacı o günün en yeni sürümlerine taşır.
node_modules Nasıl Kurulur? Hoisting, npm ls ve npm explain
Express 28 doğrudan bağımlılık getiriyor, onlar da başkalarını; laboratuvarda 69 paket kuruldu. npm bunları mümkün olduğunca node_modules'un en üst seviyesine koyar (hoisting), çünkü Node bir paketi ararken önce kendi klasöründeki node_modules'a, sonra bir üstteki node_modules'a bakarak yukarı çıkar. Aynı paketin iki uyumsuz sürümü gerekiyorsa biri üstte, diğeri onu isteyen paketin altında durur. Laboratuvarda tam olarak bu oldu:
npm ls content-type
npm-lab@1.0.0 /home/user/bookstore-api/labs/04
`-- express@5.2.1
+-- accepts@2.0.0
| `-- negotiator@1.1.0
| `-- content-type@2.1.0
+-- body-parser@2.4.0
| `-- content-type@2.1.0
+-- content-type@1.0.5
`-- type-is@2.1.0
`-- content-type@2.1.0
Express 5.2.1 content-type'ın 1.x'ini istiyor ve üstte 1.0.5 duruyor; body-parser, negotiator ve type-is 2.x istiyor ve her biri kendi node_modules'unda 2.1.0'ı tutuyor. Diskte gerçekten de üç iç içe node_modules klasörü var. "Bu paket neden kurulu?" sorusunun cevabı ise npm explain:
npm explain ms
ms@2.1.3
node_modules/ms
ms@"^2.1.3" from debug@4.4.3
node_modules/debug
debug@"^4.4.3" from body-parser@2.4.0
node_modules/body-parser
body-parser@"^2.2.1" from express@5.2.1
node_modules/express
express@"^5.2.1" from the root project
Çıktının yalnız ilk zinciri burada; tam çıktı ms'in Express'e uzanan sekiz ayrı yolunu listeliyor. Bir güvenlik bildiriminde adı geçen ve senin hiç kurmadığın bir paketi görünce ilk çalıştıracağın komut bu.
Hoisting'in bir yan etkisi var: üst seviyeye çıkan bir paketi, package.json'da yazmasan da kodundan import edebilirsin. Buna phantom dependency denir ve Express bir gün o paketi bırakınca kodun kırılır. pnpm bu yüzden farklı bir yol izliyor: paketleri ortak bir store'da tutup node_modules'a symlink'lerle bağlıyor, böylece yalnız tanımladığın paketlere erişebiliyorsun. Bu seride npm'de kalıyoruz; aynı kuralı biz disiplinle uygularız.
npm Workspaces: Bookstore İçin @bookstore/contracts
Bookstore'da sipariş durumları, ISBN (kitapların uluslararası standart numarası) kontrolü ve API'nin döndürdüğü kitap özeti gibi kurallar yalnız API'de değil, ilerideki BFF'de (backend for frontend; tek bir istemciye özel ince backend) ve araç script'lerinde de lazım. .NET'te bunun için solution'a bir class library projesi ekler ve ProjectReference verirsin. npm'de karşılığı workspace: aynı repo'daki bir klasörü ayrı bir paket olarak tanımlayıp kök projeye bağlamak. Kök package.json'a iki şey ekledim:
"workspaces": [
"packages/*"
],
"dependencies": {
"@bookstore/contracts": "^0.1.0"
}
{
"name": "@bookstore/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.js"
}
}
// API'nin ve diğer Bookstore Node.js paketlerinin paylaştığı şekiller ve kurallar
export const ORDER_STATUSES = ["Pending", "Paid", "Shipped", "Delivered", "Cancelled"];
export function isValidIsbn13(isbn) {
if (!/^97[89]\d{10}$/.test(isbn)) {
return false;
}
const sum = [...isbn].reduce((acc, ch, i) => acc + Number(ch) * (i % 2 === 0 ? 1 : 3), 0);
return sum % 10 === 0;
}
export function toBookSummary(book) {
return {
isbn: book.isbn,
title: book.title,
price: { amount: book.price, currency: book.currency },
};
}
Kökte npm install çalıştırınca npm, node_modules/@bookstore/contracts'ı packages/contracts'a bir symlink olarak bağlıyor ve lock dosyasına "link": true diye yazıyor:
npm ls --all
bookstore-api@0.1.0 /home/user/bookstore-api
`-- @bookstore/contracts@0.1.0 -> ./packages/contracts
API artık paketi adıyla kullanıyor; packages/contracts'ta yaptığın değişiklik bir sonraki çalıştırmada hemen görünür, yayınlama ya da yeniden kurulum gerekmez:
import { toBookSummary } from "@bookstore/contracts";
import { listBooks } from "#services/bookService";
// GET /api/books
export function handleListBooks(req, res) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(listBooks().map(toBookSummary)));
}
[{"isbn":"9780441172719","title":"Dune","price":{"amount":349.9,"currency":"TRY"}},{"isbn":"9780547928227","title":"The Hobbit","price":{"amount":289.5,"currency":"TRY"}},{"isbn":"9780141439600","title":"Middlemarch","price":{"amount":310,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","price":{"amount":245,"currency":"TRY"}}]
Komutları belirli bir workspace'te çalıştırmak için -w (npm run build -w @bookstore/contracts), hepsinde çalıştırmak için --workspaces kullanılır. Paketin exports alanı da önceki yazıdaki gibi çalışıyor: API yalnız src/index.js'in dışarı verdiklerini görebilir.
Supply Chain Güvenliği: Audit, Install Script'leri ve İmzalar
69 paketin her biri, yazarının hesabı ele geçirilirse ya da kötü niyetli bir sürüm yayınlanırsa senin makinende ve production'da kod çalıştırabilir. npm'in bu riske karşı araçlarına bakalım; ayrıntılı tehdit modeli ve CI'daki tarayıcılar DevSecOps serisinin konusu.
npm audit
npm audit, lock dosyasındaki sürümleri GitHub Advisory Database'deki bildirimlerle karşılaştırır. Paket kurmadan da çalışır; boş bir deneme klasöründe eski Express 4.17.1 için yalnız lock dosyası ürettim (npm install express@4.17.1 --package-lock-only) ve audit çalıştırdım. Çıktı kısaltıldı:
# npm audit report
body-parser <=1.20.8
Severity: high
body-parser vulnerable to denial of service when url encoding is enabled - https://github.com/advisories/GHSA-qwcr-r2fm-qrc7
...
path-to-regexp <=0.1.12
Severity: high
path-to-regexp outputs backtracking regular expressions - https://github.com/advisories/GHSA-9wv6-86v2-598j
...
7 vulnerabilities (3 low, 4 high)
CI'da npm audit --omit=dev --audit-level=high yüksek seviyede bir bulgu olduğunda sıfırdan farklı exit code döndürür; bu örnekte 1 döndü. npm audit fix aralığın izin verdiği düzeltmeleri kurar; --force ise major yükseltme yapabilir, ona dikkat et.
Install script'leri ve allowScripts
En büyük risk kurulum anında çalışan script'ler: bir paketin preinstall, install ya da postinstall script'i, npm install sırasında senin yetkilerinle çalışır ve bütün environment variable'larını görür. Bir laboratuvar paketiyle denedim; npm 11.20.0 script'i çalıştırıp sonunda uyardı:
isbn-tools postinstall ran with access to 106 env vars
...
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts isbn-tools@1.0.0 (postinstall: node -e "console.log('isbn-tools postinstall ran with access to', Object.keys(process.env).length, 'env vars')")
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
Bu sürümde varsayılan davranış uyarmak. Projenin .npmrc'sine strict-allow-scripts=true yazarsan onaylanmamış script'li bir paket kurulumu durdurur. Boş bir deneme klasöründe, postinstall script'i olan esbuild ile:
npm error code ESTRICTALLOWSCRIPTS
npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:
npm error esbuild@0.28.2 (postinstall: node install.js)
npm error Approve them with `npm install-scripts approve`, deny them with `npm install-scripts deny`, or bypass this check with `--dangerously-allow-all-scripts`.
Akış şöyle: paketi script'leri çalıştırmadan kur (npm install esbuild --ignore-scripts), npm install-scripts ls ile listeyi gör, script'i inceledikten sonra onayla:
npm install-scripts approve esbuild
Approved esbuild:
added esbuild@0.28.2
Onay package.json'a "allowScripts": { "esbuild@0.28.2": true } olarak yazılıyor. Sürüme sabitlendiği için esbuild yükseldiğinde script'i yeniden incelemen gerekir; bundan sonra npm ci sorunsuz çalıştı. Bookstore API'sinin .npmrc'sine de bu ayarı ekledim:
engine-strict=true
strict-allow-scripts=true
İmzalar, provenance ve paket seçimi
npm audit signatures
audited 69 packages in 1s
69 packages have verified registry signatures
5 packages have verified attestations
Registry imzası, paketin npm registry'den değişmeden geldiğini doğrular. Attestation (provenance) ise daha güçlü bir iddia: paketin hangi public repo'dan, hangi CI çalışmasında build edildiğini Sigstore ile imzalanmış olarak kaydeder. 69 paketten yalnız 5'inde olması ekosistemin bugünkü durumunu gösteriyor. Yeni bir paket eklerken şu kontrolleri alışkanlık hâline getir:
- Adı harfi harfine kontrol et;
expresssya daexpress-jsgibi typosquatting paketleri gerçek paketin adına yakın isimlerle yayınlanır. npm view <paket>ile yayıncıyı, son yayın tarihini, haftalık indirmeyi ve repo bağlantısını gör.- Paketin install script'i varsa ne yaptığını oku; yoksa ve sonradan eklenmişse bu bir red flag.
- Tek satırlık bir yardımcı fonksiyon için bağımlılık ekleme; kodu kendin yaz.
- Yeni yayınlanmış sürümlere hemen geçme; npm'in
min-release-ageayarı belirli gün sayısından yeni sürümleri kurmayı reddeder.
Üretimde Dikkat
Image build'inde npm ci --omit=dev kullan. TypeScript derleyicisi, test araçları ve tipler production image'ına girmemeli; hem image boyutu hem attack surface büyür. Build gerektiren bir proje için çok aşamalı Dockerfile'ı Docker serisinde anlatıyoruz.
CI'da önce npm ci, sonra npm audit signatures ve npm audit --audit-level=high çalıştır. Lock dosyası değişen pull request'leri ayrıca incele: kimsenin istemediği bir paket ağaca girmiş olabilir.
Bağımlılık güncellemeyi düzenli ve küçük adımlarla yap. Dependabot ya da Renovate gibi bir aracın açtığı haftalık pull request'ler, altı ayda bir yapılan büyük bir yükseltmeden çok daha kolay incelenir.
Private paketler için scope (@bookstore) kullan ve o scope'u public registry'de de kendi adına al; aksi hâlde biri aynı adla public bir paket yayınlayıp kurulumuna girebilir.
Sık Düşülen Tuzaklar
Lock dosyasını silip sorunun düzelmesini beklemek. Lock'u silmek bütün ağacı o günün en yeni sürümlerine taşır; bir sorunu çözerken on yenisini getirebilir.
devDependency'yi production image'ına taşımak. npm install ile image build etmek devDependency'leri de kurar; npm ci --omit=dev yaz.
package.json'da olmayan bir paketi import etmek. Hoisting sayesinde bugün çalışır, bağımlılığın bağımlılığı değişince kırılır.
Peer dependency hatasını --legacy-peer-deps ile susturmak. Uyuşmazlık kaybolmaz, runtime'da garip hatalar olarak geri gelir.
CI'da npm install çalıştırmak. Lock dosyasını sessizce değiştirebilir ve build'ler arasında farklı ağaçlar üretebilir.
Küçük Bir Deneme
★☆☆ labs/04/ranges.js'e "5.x", "*" ve ">=4.22.0 || ^5.0.0" aralıklarını ekle. Sonuçları çalıştırmadan önce tahmin et.
★★☆ packages/contracts'a formatPrice fonksiyonu ekle ve exports'u değiştirmeden @bookstore/contracts/src/index.js yolundan import etmeyi dene. Hangi hatayı alıyorsun? Sonra "./pricing" adlı ayrı bir subpath export tanımla.
★★★ Laboratuvar klasöründe npm ls --all --parseable çıktısından node_modules'taki iç içe kopyaları bulan küçük bir script yaz: hangi paketin kaç farklı sürümü kurulu?
Bağımlılıklar yerinde; sıra tiplerde. Bir sonraki yazıda Bookstore API'sini TypeScript'e çeviriyoruz, ama build adımı olmadan: Node 26'nın type stripping'i, erasableSyntaxOnly, tip kontrolünü ayrı bir adım olarak çalıştırmak ve bu yaklaşımın sınırları.