Node.js'te bir web sunucusu yazmak için hiçbir çatı (framework) gerekmez: yerleşik node:http modülündeki http.createServer() fonksiyonu her istek için çağrılan bir işleyici alır ve server.listen(port) ile dinlemeye başlar. İşleyiciye gelen req (http.IncomingMessage) okunabilir bir stream, res (http.ServerResponse) ise yazılabilir bir stream'dir. Yöntemi ve URL'yi req.method ile req.url'den okur, durum kodunu res.statusCode ya da res.writeHead() ile, gövdeyi res.end() ile gönderirsiniz.

Bu yazıda Node.js Learn'deki "Anatomy of an HTTP Transaction" rehberini temel alarak yönlendirme, sorgu parametreleri, istek gövdesi okuma, JSON yanıt, başlıklar, hata yönetimi, dosyayı stream ile gönderme ve sunucuyu kapatma konularını Node.js v26.13.0 ile çalıştırılmış örneklerle anlatıyoruz. Her örnek sunucuyu rastgele bir portta başlatır, kendine fetch ile bir istek atar, sonucu yazdırır ve kapanır. Express ile REST API kurmak için Node.js REST API geliştirme yazısına, ara katman mantığı için Express middleware yazısına bakabilirsiniz. Serinin tamamı: Node.js öğrenme rehberi.

İlk sunucu: createServer, listen ve yönlendirme

İlk sunucu: createServer, listen ve yönlendirme

createServer()'a verdiğiniz fonksiyon her HTTP isteğinde bir kez çağrılır; bu yüzden ona istek işleyici (request handler) denir. Learn rehberi, dönen Server nesnesinin bir EventEmitter olduğunu ve bu fonksiyonun aslında server.on('request', ...) için bir kısayol olduğunu belirtir. listen()'a port olarak 0 verirseniz, net belgesine göre işletim sistemi kullanılmayan rastgele bir port atar; atanan portu server.address().port ile okursunuz. Testlerde port çakışmasını önlemenin pratik yoludur.

javascript
import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    res.setHeader('Content-Type', 'text/plain; charset=utf-8');
    res.end('Ana sayfa');
    return;
  }
  res.statusCode = 404;
  res.end('Bulunamadı');
});

server.listen(0, async () => {
  const { port } = server.address();
  for (const yol of ['/', '/yok']) {
    const yanit = await fetch(`http://localhost:${port}${yol}`);
    console.log(yol, yanit.status, await yanit.text());
  }
  server.close();
});
// çıktı: / 200 Ana sayfa
// çıktı: /yok 404 Bulunamadı

Durum kodunu hiç ayarlamazsanız yanıt 200 olur. Learn rehberinin ifadesiyle URL ve yönteme bakarak karar vermek basit bir yönlendirme (routing) biçimidir; bu iş if ya da switch kadar basit, ya da Express gibi tam bir çatı kadar kapsamlı olabilir. Her daldan sonra return kullanmak, yanlışlıkla iki kez yanıt göndermeyi önler. Türkçe karakterli düz metin gönderirken Content-Type içine charset=utf-8 eklemek istemcinin metni doğru çözmesine yardım eder.

Yöntem, URL, sorgu parametreleri ve başlıklar

req.url, belgelerin tanımıyla yalnızca HTTP isteğinde gerçekten bulunan kısmı, yani yol ve sorgu dizesini içerir: /ara?q=node%20http&sayfa=2 gibi. Parçalara ayırmak için WHATWG URL sınıfını bir taban adresle kullanırsınız. Belgeler, taban olarak req.headers.host kullanacaksanız istemcinin Host başlığını istediği gibi gönderebileceğini, bu yüzden doğrulamanız gerektiğini hatırlatır. Başlıklar req.headers nesnesindedir ve istemci nasıl gönderirse göndersin anahtarlar küçük harfe çevrilir:

javascript
import http from 'node:http';

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const bilgi = {
    method: req.method,
    url: req.url,
    pathname: url.pathname,
    q: url.searchParams.get('q'),
    sayfa: Number(url.searchParams.get('sayfa') ?? 1),
    ajan: req.headers['x-istemci'],
  };
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify(bilgi));
});

server.listen(0, async () => {
  const port = server.address().port;
  const yanit = await fetch(`http://localhost:${port}/ara?q=node%20http&sayfa=2`, {
    headers: { 'X-Istemci': 'deneme' },
  });
  console.log(await yanit.json());
  server.close();
});
// çıktı: {
// çıktı:   method: 'GET',
// çıktı:   url: '/ara?q=node%20http&sayfa=2',
// çıktı:   pathname: '/ara',
// çıktı:   q: 'node http',
// çıktı:   sayfa: 2,
// çıktı:   ajan: 'deneme'
// çıktı: }

İstemci X-Istemci gönderdi, sunucuda req.headers['x-istemci'] olarak okuduk. searchParams.get() yüzde kodlamasını çözdü ve node%20http'yi node http'ye çevirdi. Learn rehberi, aynı başlık birden fazla kez gelirse başlığa göre değerlerin ya üzerine yazıldığını ya da virgülle birleştirildiğini, bunun sorun olduğu durumlarda ham listeyi veren req.rawHeaders'ın kullanılabileceğini belirtir. URL ve URLSearchParams ayrıntıları için URL ve URLSearchParams yazısına bakabilirsiniz.

İstek gövdesini okumak ve JSON yanıt vermek

POST ya da PUT isteğinin gövdesi başlıklar kadar kolay erişilmez, çünkü req bir okunabilir stream'dir ve gövde parça parça gelir. Learn rehberinin klasik yolu, 'data' olayında gelen Buffer parçalarını bir diziye toplamak ve 'end' olayında Buffer.concat(parcalar).toString() ile birleştirmektir. Rehber aynı işi tek satırda yapan yerleşik node:stream/consumers modülünü de gösterir: await text(req). Aşağıdaki örnek JSON gövdeyi okur, geçersizse 400, geçerliyse 201 döndürür:

javascript
import http from 'node:http';
import { text } from 'node:stream/consumers';

const server = http.createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/kullanici') {
    res.writeHead(404).end();
    return;
  }
  let veri;
  try {
    veri = JSON.parse(await text(req));
  } catch {
    res.writeHead(400, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ hata: 'Geçersiz JSON' }));
    return;
  }
  const govde = JSON.stringify({ id: 1, ad: veri.ad });
  res.writeHead(201, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(govde),
  });
  res.end(govde);
});

server.listen(0, async () => {
  const adres = `http://localhost:${server.address().port}/kullanici`;
  const iyi = await fetch(adres, { method: 'POST', body: JSON.stringify({ ad: 'Çağla' }) });
  console.log(iyi.status, iyi.headers.get('content-length'), await iyi.json());
  // çıktı: 201 23 { id: 1, ad: 'Çağla' }
  const kotu = await fetch(adres, { method: 'POST', body: '{ad:' });
  console.log(kotu.status, await kotu.json()); // çıktı: 400 { hata: 'Geçersiz JSON' }
  server.close();
});

Content-Length olarak 23 gönderildi: gövde 22 karakterdir ama "Ç" UTF-8'de iki bayt tuttuğu için bayt sayısı 23'tür. Belgeler Content-Length'in karakter değil bayt cinsinden olduğunu, Node'un gönderilen gövde ile bu değeri karşılaştırdığını ve bu yüzden Buffer.byteLength() kullanılması gerektiğini açıkça yazar; ayrıntı Buffer yazısında. text() tüm gövdeyi belleğe alır ve kendi başına bir boyut sınırı uygulamaz. Dışarıya açık bir sunucuda, gövdeyi okurken toplam bayt sayısını sayıp belirli bir sınırı aşan isteği reddetmek sizin sorumluluğunuzdadır.

Durum kodu ve başlıklar: setHeader ile writeHead farkı

Durum kodu ve başlıklar: setHeader ile writeHead farkı: Durum kodu, Tek başlık, Durum ve başlıkları birlikte, Gövde parçası

Başlıkları iki yolla ayarlarsınız. res.setHeader(ad, deger) başlığı bir iç tabloya yazar; ad büyük/küçük harfe duyarsızdır ve aynı başlığı tekrar ayarlarsanız son değer gönderilir. Learn rehberi buna "örtük başlıklar" der: Node başlıkları gövdenin ilk baytından hemen önce sizin yerinize gönderir. res.writeHead(durum, basliklar) ise durum satırını ve başlıkları açıkça yazar. Belgelere göre ikisi birlikte kullanılırsa başlıklar birleştirilir ve writeHead'e verilenler önceliklidir. Başlıklar bir kez gönderildikten sonra değiştirilemez:

javascript
import http from 'node:http';

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html');
  res.setHeader('X-Ornek', 'bir');
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  console.log('headersSent:', res.headersSent); // çıktı: headersSent: true
  try {
    res.setHeader('X-Gec', 'iki');
  } catch (err) {
    console.log(err.code); // çıktı: ERR_HTTP_HEADERS_SENT
  }
  res.end('tamam');
});

server.listen(0, async () => {
  const yanit = await fetch(`http://localhost:${server.address().port}/`);
  console.log(yanit.headers.get('content-type'), yanit.headers.get('x-ornek')); // çıktı: text/plain bir
  console.log(http.STATUS_CODES[404], http.STATUS_CODES[201]); // çıktı: Not Found Created
  server.close();
});

Content-Type için writeHead'deki text/plain kazandı, X-Ornek ise setHeader'dan geldi. writeHead() çağrıldıktan sonra headersSent true oldu ve yeni başlık eklemek ERR_HTTP_HEADERS_SENT hatası verdi. Bu, en sık karşılaşılan Node HTTP hatalarından biridir: genellikle bir işleyicide yanıt iki kez gönderildiğinde ya da bir hata yolunda return unutulduğunda ortaya çıkar. Learn rehberi bunu şöyle özetler: durum ve başlıkları gövdeye yazmaya başlamadan önce ayarlayın, çünkü HTTP yanıtında başlıklar gövdeden önce gelir. http.STATUS_CODES standart durum kodlarının kısa açıklamalarını içeren bir nesnedir. Belgeler ayrıca writeHead()'in bir yanıtta yalnızca bir kez ve end()'den önce çağrılması gerektiğini, durum kodu 100-999 aralığında değilse RangeError fırlatıldığını belirtir.

İşYöntemNot
Durum kodures.statusCode = 404Ayarlanmazsa 200
Tek başlıkres.setHeader(ad, değer)Sonradan getHeader ile okunabilir
Durum ve başlıkları birlikteres.writeHead(201, {...})Bir kez, end öncesinde
Gövde parçasıres.write(parça)Başlıklar ilk write ile gider
Yanıtı bitirmekres.end([son parça])Her yanıtta mutlaka çağrılmalı
Gönderildi mi?res.headersSenttrue ise başlık değiştirilemez

Aynı adla birden fazla başlık göndermeniz gerektiğinde (en tipik örnek birden fazla çerez için Set-Cookie), belgelere göre setHeader'a değer olarak bir string dizisi verirsiniz: res.setHeader('Set-Cookie', ['a=1', 'b=2']). setHeader'a verilen sayı gibi string olmayan değerler olduğu gibi saklanır, bu yüzden getHeader() string olmayan değer döndürebilir; ağa gönderilirken string'e çevrilirler. Bir de ince nokta var: setHeader hiç çağrılmadan yalnızca writeHead kullanılırsa başlıklar iç tabloya kaydedilmeden doğrudan ağa yazılır ve sonradan getHeader() ile okunamaz. Başlıkları adım adım oluşturup sonradan okumak ya da değiştirmek istiyorsanız belgeler setHeader kullanmayı önerir. Geçersiz karakter içeren başlık adı ya da değeri her iki yöntemde de TypeError fırlatır.

Hatalar: req ve res stream olayları

req bir stream olduğu için hata bir 'error' olayıyla gelir. Learn rehberi kalın harflerle uyarır: bu olayın dinleyicisi yoksa hata fırlatılır ve Node.js programınızı çökertebilir. Bu yüzden istek stream'ine en azından hatayı kaydeden bir dinleyici ekleyin; mümkünse 400 gibi bir HTTP hata yanıtı da gönderin. res da 'error' olayı yayabilir ve aynı kural ona da uygulanır. Async işleyicilerde ise await edilen bir işlemin reddi yakalanmazsa istemci yanıt alamadan bekler; işleyicinin gövdesini try/catch ile sarıp 500 döndürmek iyi bir alışkanlıktır. Yakalanmamış hata ve reddedilmiş Promise davranışı için uncaughtException ve unhandledRejection yazısına bakabilirsiniz.

Dosyayı stream ile göndermek

Büyük bir dosyayı önce belleğe okuyup sonra göndermek yerine, dosya stream'ini doğrudan yanıta bağlayabilirsiniz. Learn rehberi bunun için pipe() kullanır; hataları da yöneten pipeline() daha güvenlidir. Content-Length vermediğiniz için Node yanıtı chunked aktarım kodlamasıyla gönderir:

javascript
import http from 'node:http';
import { createReadStream } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';

await writeFile('rapor.txt', 'satır\n'.repeat(5000));

const server = http.createServer(async (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  try {
    await pipeline(createReadStream('rapor.txt'), res);
  } catch (err) {
    console.error('akış hatası:', err.code);
  }
});

server.listen(0, async () => {
  const yanit = await fetch(`http://localhost:${server.address().port}/`);
  const govde = await yanit.text();
  console.log(yanit.headers.get('transfer-encoding'), govde.length); // çıktı: chunked 30000
  server.close(() => console.log('sunucu kapandı')); // çıktı: sunucu kapandı
});

Zaman aşımları ve sunucuyu kapatmak

http.Server üzerinde güvenlik açısından önemli birkaç zaman aşımı vardır. server.requestTimeout tüm isteğin alınması için verilen süredir; v18.0.0 ile varsayılanı zaman aşımı yokken 300000 ms'ye (5 dakika) çekildi. server.headersTimeout yalnızca başlıkların tamamlanması için beklenen süredir ve varsayılanı requestTimeout ile 60000 ms'nin küçüğüdür. Bu sürelerden biri dolarsa sunucu isteği işleyiciye iletmeden 408 döndürür ve bağlantıyı kapatır. Belgeler, sunucu önünde bir ters vekil (reverse proxy) olmadan yayına alınıyorsa bu değerlerin hizmet engelleme (DoS) saldırılarına karşı sıfırdan farklı tutulması gerektiğini vurgular. server.keepAliveTimeout (varsayılan 5000 ms) son yanıttan sonra boşta kalan bağlantının ne kadar açık tutulacağını belirler.

server.close() yeni bağlantı kabul etmeyi durdurur ve o anda istek göndermeyen ya da yanıt beklemeyen bağlantıları kapatır; v19.0.0'dan beri boşta bekleyen keep-alive bağlantıları da kapatır. Devam eden istekleri zorla kesmek için server.closeAllConnections() vardır; belgeler bunu dikkatli kullanmayı ve yarış durumlarını önlemek için server.close()'dan sonra çağırmayı önerir. Üretimde sinyal yakalayarak düzgün kapanma (graceful shutdown) ve çok çekirdek kullanımı cluster ve PM2 yazısında anlatılıyor.

Keep-alive ile ilgili bir ayrıntı daha: YAML kayıtlarına göre v24.6.0 ve v22.19.0 ile gelen server.keepAliveTimeoutBuffer (varsayılan 1000 ms), ilan edilen keep-alive süresine eklenen bir tampon süredir. Gerçek soket zaman aşımı keepAliveTimeout + keepAliveTimeoutBuffer olarak hesaplanır. Belgelere göre amaç, istemcinin tam kapanma anında bağlantıyı yeniden kullanmaya çalışmasıyla oluşan ECONNRESET hatalarını azaltmaktır. Bu ayarlar ve server.timeout yalnızca yeni bağlantılara uygulanır, var olan bağlantıları etkilemez. Bağlantı başına istek sayısını sınırlamak için server.maxRequestsPerSocket vardır; sınıra ulaşıldığında sunucu Connection: close başlığı gönderir ve sonraki isteklere 503 döner.

Uygulamalı mini örnek: küçük bir JSON API iskeleti

Yukarıdaki parçaları birleştirince çatısız bir API'nin iskeleti ortaya çıkar: işleyici önce new URL(req.url, taban) ile yolu ayrıştırır; yöntem ve yol çiftine göre bir fonksiyon seçer; gövde gerektiren uçlarda text(req) ile okuyup JSON.parse eder ve hatalıysa 400 döner; sonucu JSON.stringify ile üretip Content-Type ve Buffer.byteLength ile hesaplanmış Content-Length başlıklarıyla writeHead + end ile gönderir; bilinmeyen yollarda 404, beklenmeyen istisnalarda 500 verir. Uç sayısı arttıkça yönlendirme tablosu, ara katmanlar ve doğrulama ihtiyacı büyür; bu noktada bir çatıya geçmek mantıklıdır. Ama her çatının altında yine bu req/res nesnelerinin çalıştığını bilmek hata ayıklamayı çok kolaylaştırır. TCP düzeyinde bir sunucu için net modülü yazısına, WebSocket ile SSE karşılaştırması için WebSocket ve SSE yazısına bakabilirsiniz.

Sık yapılan hatalar

Sık yapılan hatalar: Bir dalda res.end() çağırmayı unutmak, Yanıtı iki kez göndermek, Content-Length'i string.length ile hesaplamak, req.url'yi doğrudan dosya yoluna eklemek
  • Bir dalda res.end() çağırmayı unutmak: İstemci yanıt alamaz ve zaman aşımına kadar bekler.
  • Yanıtı iki kez göndermek: end()'den sonra return unutulunca ikinci writeHead ERR_HTTP_HEADERS_SENT verir.
  • Content-Length'i string.length ile hesaplamak: Türkçe karakterlerde bayt sayısı farklıdır; Buffer.byteLength() kullanın.
  • req.url'yi doğrudan dosya yoluna eklemek: ../ ile klasör dışına çıkılabilir; yolu normalleştirip sınırlayın.
  • Gövde boyutunu sınırlamamak: text(req) her şeyi belleğe alır; büyük gövdeleri sayarak reddedin.
  • 'error' dinleyicisi eklememek: Learn rehberine göre işlenmeyen stream hatası süreci çökertebilir.
  • Zaman aşımlarını sıfıra çekmek: Önünde vekil olmayan sunucuda DoS riskini artırır.

Sık Sorulan Sorular

node:http ile HTTPS sunucusu kurabilir miyim?

HTTPS için ayrı node:https modülü vardır ve sertifika ile anahtar gerektirir. Yaygın üretim kurulumunda ise TLS, önde duran bir ters vekilde sonlandırılır ve Node uygulaması yerel ağda düz HTTP dinler.

req.body neden yok?

node:http gövdeyi sizin için ayrıştırmaz; req.body çatıların eklediği bir alandır. Gövdeyi stream'den okumanız (ör. await text(req)) ve içerik türüne göre ayrıştırmanız gerekir.

Port zaten kullanımdaysa ne olur?

listen() bir 'error' olayı yayar ve hata kodu EADDRINUSE olur. Bu ve benzeri sistem hataları EADDRINUSE ve ENOENT yazısında anlatılıyor.

Express yerine neden node:http öğrenmeliyim?

Express, Fastify gibi çatılar bu modülün üzerine kurulur. Akış, başlık, zaman aşımı ve hata davranışını bilmek, çatı kullanırken karşılaştığınız sorunları anlamanızı sağlar; küçük servislerde ise bağımlılık eklemeden iş görür.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: Node.js öğrenme rehberi · fetch ile HTTP isteği · Node.js stream nedir · HTTP yanıtını sıkıştırmak · Node.js REST API geliştirme · cluster ve PM2 ile production