Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #10

node:http ile Çıplak HTTP Server: Request/Response, Routing'i Elle Yazmak ve Neden Framework Gerekir

Mert Özen 14 Kas 2026 15 dk 10 görüntülenme
node:http ile Çıplak HTTP Server: Request/Response, Routing'i Elle Yazmak ve Neden Framework Gerekir

node:http, Node'da HTTP'nin en alt katıdır: body'yi boyut limitiyle okumak, routing, validation ve hata yönetimi senin işindir; async bir handler'daki tek bir hata bile process'i kapatabilir. Gerçek çıktılarla çöken sunucu, stream olarak okunan JSON body ve 413/415/422 cevapları, elle yazılmış router, client gidince durdurulan worker, :: üzerinde dinleyen listen(), slowloris'e 408 dönen headersTimeout ve 413'ten sonra açık kalan bağlantı.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sini şimdiye kadar çıplak node:http üzerinde yazdık. Şu handler'da bir bug var: bulunamayan bir kitabın başlığını okumaya çalışıyor.

import { createServer } from "node:http";

// Bir async handler, bir bug: client ne alır?
const server = createServer(async (req, res) => {
    if (req.url === "/api/books/9780000000000") {
        const book = undefined as { title: string } | undefined;
        res.end(book!.title);
        return;
    }
    res.end("ok");
});

server.listen(3100, "127.0.0.1", () => console.log("listening on 3100"));

ASP.NET Core'da bu bir NullReferenceException olurdu ve client 500 alırdı, sunucu da çalışmaya devam ederdi. Node'da client ne görür? Hatadan sonra gelen istek ne olur?

curl -sS http://127.0.0.1:3100/health
curl -sS http://127.0.0.1:3100/api/books/9780000000000
curl -sS http://127.0.0.1:3100/health
ok
curl: (52) Empty reply from server
curl: (7) Failed to connect to 127.0.0.1 port 3100 after 0 ms: Couldn't connect to server
file:///home/user/bookstore-api/labs/10/guess.ts:7
        res.end(book .title);
                      ^

TypeError: Cannot read properties of undefined (reading 'title')
    at Server.<anonymous> (file:///home/user/bookstore-api/labs/10/guess.ts:7:23)
    at Server.emit (node:events:514:20)
    at parserOnIncoming (node:_http_server:1464:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)

Node.js v26.13.1
server exit=1

Client cevap alamadı, sunucu exit code 1 ile kapandı ve sonraki istek bağlanamadı. Handler async; createServer onun döndürdüğü promise'e bakmıyor, rejection unhandled kalıyor ve process'i düşürüyor (07). Hata satırında book .title yazması da 05'teki type stripping'in izi: ! bir boşlukla değiştirilmiş, sütun numaraları bu yüzden kaymıyor.

node:http bir framework değil, HTTP protokolünün Node'daki en alt katı. Bu yazıda o katı yakından tanıyoruz: request ve response object'leri, body'yi boyut limitiyle okumak, elle bir routing tablosu, merkezi hata yönetimi, keep-alive ve timeout ayarları. Bookstore'a POST /api/orders'ı framework'süz ekliyoruz ve sonunda Express'e geçmek için gerekçelerimizi listeliyoruz.

IncomingMessage ve ServerResponse

createServer'a verdiğin callback her request için iki object alır. req bir IncomingMessage: bir Readable stream (09) ve üzerinde method, url, headers var. url yalnız path ve query string'den oluşur (/api/books?page=2); parse etmek için new URL(req.url, "http://localhost") kullanıyoruz. Header adları küçük harfe çevrilmiş gelir: req.headers["content-type"]. Aynı header birden fazla gelirse çoğu virgülle birleştirilir, set-cookie gibi bazıları array olur; ham hâli rawHeaders'ta.

res bir ServerResponse: bir Writable stream. Status ve header'ları writeHead(status, headers) ile tek seferde ya da statusCode ve setHeader ile parça parça verirsin; ilk write ya da end çağrısında header'lar gönderilir ve res.headersSent true olur. Bundan sonra status değişmez. Node 26'da eskiden kalma writeHeader alias'ı tamamen kaldırıldı; yalnız writeHead var. Bookstore'da JSON cevapları tek bir yardımcıdan geçiyor:

import type { ServerResponse } from "node:http";

export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
    const json = JSON.stringify(body);
    res.writeHead(status, {
        "content-type": "application/json; charset=utf-8",
        "content-length": Buffer.byteLength(json),
        ...headers,
    });
    res.end(json);
}

content-length'i string'in length'inden değil Buffer.byteLength'ten hesaplıyoruz; 08'de gördük, Türkçe karakter içeren bir string'in byte sayısı karakter sayısından büyük. Yanlış bir content-length client'ın cevabı yarıda kesmesine yol açar.

Body'yi Stream Olarak Okumak ve Boyut Limiti

ASP.NET Core body'yi senin için okuyup [FromBody] parametresine bağlar ve Kestrel'in varsayılan 30 MB'lık MaxRequestBodySize sınırı vardır. node:http'de body bir stream ve hiçbir sınır yok; okumazsan hiç okunmaz, sınır koymazsan client istediği kadar gönderir:

import type { IncomingMessage } from "node:http";
import { HttpError } from "#http/errors";

// JSON body'yi stream olarak okur ve sınırı geçtiği anda durur
export async function readJsonBody(req: IncomingMessage, limitBytes = 64 * 1024): Promise<unknown> {
    const contentType = req.headers["content-type"] ?? "";
    if (!/^application\/json\b/i.test(contentType)) {
        throw new HttpError(415, "content-type must be application/json");
    }
    const declared = Number(req.headers["content-length"] ?? 0);
    if (declared > limitBytes) {
        throw new HttpError(413, `body larger than ${limitBytes} bytes`);
    }

    const chunks: Buffer[] = [];
    let received = 0;
    for await (const chunk of req as AsyncIterable<Buffer>) {
        received += chunk.length;
        if (received > limitBytes) {
            throw new HttpError(413, `body larger than ${limitBytes} bytes`);
        }
        chunks.push(chunk);
    }
    try {
        return JSON.parse(Buffer.concat(chunks).toString("utf8"));
    } catch {
        throw new HttpError(400, "body is not valid JSON");
    }
}

İki kontrol var, çünkü content-length gönderilmeyebilir: Transfer-Encoding: chunked ile gelen bir body'nin boyutunu ancak okurken öğrenirsin. Bu yüzden asıl sınır döngünün içindeki sayaç. Chunk'ları Buffer.concat ile birleştirip ondan sonra decode ediyoruz; 08'deki chunk sınırında bölünen Türkçe karakter sorunu burada da geçerli. HttpError, status'unu bilen küçük bir Error alt class'ı; status'u nasıl HTTP cevabına çevirdiğimizi birazdan göreceğiz.

Elle Bir Routing Tablosu

06'dan beri server.ts'te bir if zinciri vardı. Path parametresi (/api/books/:isbn) gelince bu zincir dağılıyor. Küçük bir router yazalım: her route bir method, bir path pattern'i ve bir handler.

// "/api/books/:isbn" -> /^\/api\/books\/([^/]+)$/ ve keys ["isbn"]
function compile(path: string): { pattern: RegExp; keys: string[] } {
    const keys: string[] = [];
    const source = path
        .split("/")
        .map((segment) => {
            if (segment.startsWith(":")) {
                keys.push(segment.slice(1));
                return "([^/]+)";
            }
            return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        })
        .join("/");
    return { pattern: new RegExp(`^${source}$`), keys };
}

function decodeSegment(segment: string): string {
    try {
        return decodeURIComponent(segment);
    } catch {
        // "%E0%A4" gibi bozuk escape'ler client'ın hatası, 500 değil
        throw new HttpError(400, `malformed path segment: ${segment}`);
    }
}

export class Router {
    #routes: Route[] = [];

    add(method: string, path: string, handler: Handler): this {
        this.#routes.push({ method, ...compile(path), handler });
        return this;
    }

    // Handler'ı döndürür; yalnız method yanlışsa 405 için izinli method'ları, hiç eşleşme yoksa undefined
    match(method: string, pathname: string): { handler: Handler; params: Params } | { allowed: string[] } | undefined {
        const allowed: string[] = [];
        for (const route of this.#routes) {
            const m = route.pattern.exec(pathname);
            if (!m) {
                continue;
            }
            if (route.method !== method) {
                allowed.push(route.method);
                continue;
            }
            const params = Object.fromEntries(route.keys.map((key, i) => [key, decodeSegment(m[i + 1])]));
            return { handler: route.handler, params };
        }
        return allowed.length > 0 ? { allowed } : undefined;
    }
}

Route'lar sırayla deneniyor ve ilk eşleşen kazanıyor. Bu yüzden /api/orders/export, /api/orders/:id'den önce kayıtlı; ters sırada export isteği id = "export" olan bir sipariş aramasına dönüşürdü. ASP.NET Core'un routing'i ise sıraya değil önceliğe bakar: sabit bir segment her zaman parametreli segmentten önce eşleşir. Node 24'ten beri global olan URLPattern bu işi standart bir API ile yapıyor, ama Node 26 dokümanında hâlâ experimental (stability 1); 11'de bu router'ın yerini Express alacağı için kendi küçük sürümümüzde kaldım.

Bookstore: POST /api/orders ve Merkezi Hata Yönetimi

Route tablosu artık şöyle:

const router = new Router()
    .add("GET", "/health", (req, res) => handleHealth(req, res))
    .add("GET", "/api/books", (req, res) => handleListBooks(req, res))
    .add("GET", "/api/books/:isbn", handleGetBook)
    .add("POST", "/api/orders", (req, res) => handlePlaceOrder(req, res))
    .add("GET", "/api/orders/export", (req, res, { url }) => handleOrderExport(req, res, url))
    .add("GET", "/api/orders/:id", handleGetOrder)
    .add("GET", "/api/reports/prices", (req, res, { url }) => handlePriceReport(req, res, url))
    .add("GET", "/diagnostics/event-loop", (req, res) => handleEventLoopDelay(req, res));

POST /api/orders, 07'deki OrderService.placeOrder'ı çağırıyor. Ama önce body'nin şeklini kontrol etmemiz gerekiyor ve node:http'de bunu da elle yazıyoruz:

// Elle yazılmış validation: tam olarak bir şema kütüphanesinin devralacağı kısım
function parsePlaceOrder(body: unknown): { customerId: string; lines: OrderLine[] } {
    const errors: string[] = [];
    const input = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
    if (typeof input.customerId !== "string" || input.customerId.trim() === "") {
        errors.push("customerId must be a non-empty string");
    }
    if (!Array.isArray(input.lines) || input.lines.length === 0 || input.lines.length > 50) {
        errors.push("lines must be an array with 1 to 50 items");
    } else {
        input.lines.forEach((line: { isbn?: unknown; quantity?: unknown }, i: number) => {
            if (typeof line?.isbn !== "string") {
                errors.push(`lines[${i}].isbn must be a string`);
            }
            if (!Number.isInteger(line?.quantity) || (line.quantity as number) < 1 || (line.quantity as number) > 20) {
                errors.push(`lines[${i}].quantity must be an integer between 1 and 20`);
            }
        });
    }
    if (errors.length > 0) {
        throw new HttpError(400, errors.join("; "));
    }
    return { customerId: input.customerId as string, lines: input.lines as OrderLine[] };
}

// POST /api/orders
export async function handlePlaceOrder(req: IncomingMessage, res: ServerResponse): Promise<void> {
    const { customerId, lines } = parsePlaceOrder(await readJsonBody(req));
    try {
        const order = orderService.placeOrder(customerId, lines);
        sendJson(res, 201, order, { location: `/api/orders/${order.id}` });
    } catch (err) {
        if (err instanceof UnknownBookError) {
            throw new HttpError(422, err.message);
        }
        throw err;
    }
}

Önce Tahmin Et'teki çökmeyi önleyen kısım server.ts'te: router'dan dönen handler'ı await ediyoruz ve her hatayı tek bir catch'te HTTP cevabına çeviriyoruz. Bu blok, ASP.NET Core'daki exception handling middleware'inin elle yazılmış hâli:

try {
    const match = router.match(req.method ?? "GET", url.pathname);
    if (!match) {
        throw new HttpError(404, `no route for ${url.pathname}`);
    }
    if ("allowed" in match) {
        res.setHeader("allow", match.allowed.join(", "));
        throw new HttpError(405, `${req.method} not allowed on ${url.pathname}`);
    }
    await match.handler(req, res, { url, params: match.params });
} catch (err) {
    if (aborted.signal.aborted) {
        log("warn", "client went away", { method: req.method, path: url.pathname, durationMs: Math.round(performance.now() - started) });
    } else if (res.headersSent) {
        log("error", "error after the response started", { error: (err as Error).message });
        res.destroy();
    } else if (err instanceof HttpError) {
        // 413'ten sonra body'nin geri kalanı hâlâ yolda: hepsini okumak yerine bağlantıyı kapat
        sendJson(res, err.status, { error: err.message }, err.status === 413 ? { connection: "close" } : {});
    } else {
        log("error", "unhandled error", { error: (err as Error).stack });
        sendJson(res, 500, { error: "internal server error" });
    }
}

Dört durum var. Client çoktan gittiyse cevap yazmanın anlamı yok. Header'lar gönderildiyse (örneğin export'un ortasında) status değiştirilemez; bağlantıyı keserek client'a cevabın yarım kaldığını belli ediyoruz. HttpError kendi status'uyla, geri kalan her şey stack trace'i log'a yazılıp client'a ayrıntısız bir 500 olarak dönüyor. Deneyelim:

curl -s -i -X POST http://127.0.0.1:3000/api/orders -H "content-type: application/json" \
  -d '{"customerId":"ada","lines":[{"isbn":"9780441172719","quantity":2},{"isbn":"9780141439587","quantity":1}]}'
HTTP/1.1 201 Created
x-request-id: 41a10140-a855-481a-a5aa-3783d15d38f9
content-type: application/json; charset=utf-8
content-length: 217
location: /api/orders/ORD-1001
Date: Sat, 14 Nov 2026 18:57:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"ORD-1001","customerId":"ada","status":"Pending","items":[{"isbn":"9780441172719","quantity":2,"unitPrice":349.9},{"isbn":"9780141439587","quantity":1,"unitPrice":245}],"createdAtUtc":"2026-11-14T18:57:09.708Z"}

Hatalı istekleri de tek tek gönderdim (her satırın sonundaki sayı status kodu):

{"error":"customerId must be a non-empty string; lines[0].isbn must be a string; lines[0].quantity must be an integer between 1 and 20"} 400
{"error":"unknown ISBN 9780000000000"} 422
{"error":"body is not valid JSON"} 400
{"error":"content-type must be application/json"} 415
{"error":"body larger than 65536 bytes"} 413
{"error":"body larger than 65536 bytes"} 413
{"error":"malformed path segment: %E0%A4"} 400
{"error":"no route for /nope"} 404

Sırasıyla: şekli bozuk body, katalogda olmayan ISBN (ISBN: kitapların uluslararası standart numarası), yarım JSON, form gönderimi, 70 KB'lık body (bir kez content-length ile, bir kez Transfer-Encoding: chunked ile), bozuk bir path escape'i ve olmayan bir route. DELETE /api/orders/ORD-1001 ise 405 Method Not Allowed ve allow: GET header'ı aldı. Hata formatı şimdilik tek bir error alanı; ASP.NET Core'un ProblemDetails'ıyla aynı sözleşmeye 12'de geçeceğiz.

Client gittiğinde: request signal'i

07'de söz verdiğimiz parça: ASP.NET Core'un HttpContext.RequestAborted'ının karşılığı. Response tamamlanmadan close event'i gelirse client bağlantıyı kapatmış demektir; bunu bir AbortController'a bağlayıp signal'i request bağlamına koyuyoruz:

const aborted = new AbortController();
res.on("close", () => {
    if (!res.writableFinished) {
        aborted.abort(new Error("client closed the connection"));
    }
});

Fiyat raporunun worker modu bu signal'i kullanıyor: events.once'a verilen signal abort olunca bekleme reject oluyor ve finally worker'ı terminate() ile durduruyor.

// Çağıran vazgeçtiyse thread'i durdurur; normal bitişten sonra zararsız
export async function buildPriceReportInWorker(rows: number, signal?: AbortSignal): Promise<PriceReport> {
    const worker = new Worker(workerUrl, { workerData: { rows } });
    try {
        const [report] = (await once(worker, "message", { signal })) as [PriceReport];
        return report;
    } finally {
        await worker.terminate();
    }
}
curl -s -m 0.3 "http://127.0.0.1:3000/api/reports/prices?mode=worker"; echo "curl exit=$?"
curl exit=28
{"time":"2026-11-14T18:57:15.758Z","level":"warn","requestId":"79fa5293-9304-4030-a9a1-5be51f1046df","msg":"client went away","method":"GET","path":"/api/reports/prices","durationMs":309}

curl 300 ms'de vazgeçti (exit code 28 timeout demek); sunucu 309 ms'de bunu fark etti ve raporun geri kalanını hesaplamadı.

listen(), Keep-Alive ve Timeout'lar

Varsayılanları görelim:

import { createServer, maxHeaderSize } from "node:http";

const server = createServer();
console.log({
    headersTimeout: server.headersTimeout,
    requestTimeout: server.requestTimeout,
    keepAliveTimeout: server.keepAliveTimeout,
    timeout: server.timeout,
    maxRequestsPerSocket: server.maxRequestsPerSocket,
    maxHeaderSize,
});
server.listen(0, () => {
    console.log("listen() without a host:", server.address());
    server.close();
});
{
  headersTimeout: 60000,
  requestTimeout: 300000,
  keepAliveTimeout: 5000,
  timeout: 0,
  maxRequestsPerSocket: 0,
  maxHeaderSize: 16384
}
listen() without a host: { address: '::', family: 'IPv6', port: 55179 }

Önce son satır. Host vermeden listen çağırırsan Node ::'i, yani bütün IPv6 ve (dual-stack sistemlerde) IPv4 adreslerini dinler. Geliştirme makinende API'n bu durumda aynı ağdaki herkese açıktır. Kestrel ise varsayılan olarak yalnız localhost'u dinler. 07'den beri Bookstore'da HOST varsayılanı 127.0.0.1; container'da HOST=0.0.0.0 verilecek, çünkü container'ın içindeki loopback'e dışarıdan ulaşılamaz. Bir not daha: localhost adı bazı sistemlerde önce IPv6'daki ::1'e çözülür. Aynı port'ta ::1'i dinleyen başka bir process varsa curl localhost:3000 senin sunucuna değil ona gider; şüphelendiğinde adresi açıkça yaz.

Timeout'lar request'in farklı aşamalarını kapsıyor:

  • headersTimeout (60 sn): bağlantı açıldıktan sonra header'ların tamamının gelmesi için verilen süre.
  • requestTimeout (300 sn): body dahil isteğin tamamının alınması için verilen süre. Cevabın süresini kapsamıyor; 09'daki uzun export bu yüzden etkilenmiyor.
  • keepAliveTimeout (5 sn): bir cevaptan sonra boşta kalan bağlantının açık tutulduğu süre; cevapta Keep-Alive: timeout=5 olarak görünüyor.
  • timeout (0): socket'te hiç hareket olmadığında bağlantıyı kapatma süresi; varsayılan olarak kapalı.

headersTimeout ve requestTimeout'un amacı slowloris saldırısı: client bir bağlantı açar, header'ları bir byte bir byte gönderir ve hiç bitirmez. Yeterince bağlantı açılınca sunucu yeni istek kabul edemez. Süreyi kısaltıp deneyelim:

import { createServer } from "node:http";
import { connect } from "node:net";

// Demo kısa sürsün diye headersTimeout 2 sn; kontrol 500 ms'de bir yapılıyor
const server = createServer({ headersTimeout: 2_000, connectionsCheckingInterval: 500 }, (req, res) => res.end("ok"));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };

// Yavaş bir client: request satırını gönderiyor, sonra her 300 ms'de bir header byte'ı, hiç bitirmeden
const started = performance.now();
const socket = connect(port, "127.0.0.1", () => socket.write("GET /api/books HTTP/1.1\r\nHost: localhost\r\n"));
const drip = setInterval(() => socket.write("X"), 300);
socket.on("data", (data) => console.log(`after ${Math.round(performance.now() - started)} ms the server sent: ${data.toString().split("\r\n")[0]}`));
socket.on("close", () => {
    console.log(`connection closed after ${Math.round(performance.now() - started)} ms`);
    clearInterval(drip);
    server.close();
});
after 2008 ms the server sent: HTTP/1.1 408 Request Timeout
connection closed after 2009 ms

Client sürekli byte gönderdiği hâlde, header'ları 2 saniyede bitiremediği için 408 Request Timeout aldı ve bağlantı kapandı. Kontrol connectionsCheckingInterval aralığıyla yapıldığı için süre birkaç ms sapabilir. Bookstore'da bu değerleri createServer'a açıkça veriyoruz; header'lar için 10, bütün istek için 30 saniye bir JSON API için yeterli:

const server = createServer(
    {
        // Varsayılana eşit olsalar bile açıkça yazılı; code review'da görünsünler
        headersTimeout: 10_000,
        requestTimeout: 30_000,
        keepAliveTimeout: 5_000,
        maxHeaderSize: 16 * 1024,
    },
    (req, res) => {
        // ...
    },
);

Keep-alive'ın production'da bilinen bir tuzağı var: önünde bir load balancer varsa, Node'un keepAliveTimeout'u load balancer'ın boşta bekleme süresinden kısa olursa, Node bağlantıyı kapattığı anda load balancer aynı bağlantıdan yeni bir istek gönderebilir ve client 502 görür. Node'un süresini load balancer'ınkinden uzun tut. Kapanış tarafında server.close() yeni bağlantı kabul etmeyi bırakır ve açık istekler bitince kapanır; boştaki keep-alive bağlantılarını da kapatır. Graceful shutdown'u 14'te bunun üzerine kuracağız.

İstemci tarafında http.request hâlâ var, ama 07 ve 09'da kullandığımız global fetch (Node 26'da undici 8) yeni kod için doğru seçim: bağlantıları varsayılan olarak yeniden kullanıyor, AbortSignal kabul ediyor ve body'yi stream olarak veriyor.

Neden Bir Framework Gerekiyor?

Bu yazıda elle yazdıklarımızın listesi ve karşılıkları:

Elle yazdığımızASP.NET CoreExpress 5
Route tablosu, path parametreleri, 405Endpoint routingapp.get, Router, req.params
readJsonBody ve boyut limitiModel binding, MaxRequestBodySizeexpress.json({ limit })
parsePlaceOrderData annotations, FluentValidationYok; zod gibi bir kütüphane (12)
Merkezi try/catchException handling middlewareError middleware; rejected promise'ler otomatik
Request id ve logMiddleware, ILoggerMiddleware
Request signal'iRequestAbortedYok; aynı yöntem

Eksik olanlar da var: query string'i tipli okumak, content negotiation, static dosya sunmak, CORS (başka origin'lerden gelen tarayıcı isteklerine izin verme kuralları), cookie, middleware'leri route'a ya da route grubuna bağlamak. Her biri yazılabilir, ama her biri bakımı senin üzerinde kalan ve güvenlik açığı çıkarabilecek bir kod. Bu katmanı denenmiş bir framework'e bırakmak için yeterli gerekçe.

Üretimde Dikkat

Her body'ye bir sınır koy. node:http'nin varsayılanı sınırsız; JSON için 64 KB–1 MB arası çoğu API için yeterli, dosya yükleme gibi istisnaları route bazında aç.

Timeout'ları kodda açıkça yaz ve önündeki katmanlarla uyumlu tut: load balancer'ın boşta bekleme süresi, reverse proxy'nin timeout'ları ve Node'un keepAliveTimeout'u birlikte düşünülmeli.

Hiçbir handler hatası process'i düşürmesin. Merkezi catch olmadan async bir handler'daki her bug bir kesinti demek; bunu Önce Tahmin Et'te gördük.

Production'da dinlediğin adresi bil. Container'da 0.0.0.0, geliştirmede 127.0.0.1; host vermeden listen çağırmak her yerde bütün arayüzleri açar.

Sık Düşülen Tuzaklar

createServer'a async callback verip hatayı yakalamamak. Rejection unhandled kalır ve process kapanır.

content-length'i string.length ile hesaplamak. Türkçe karakterli bir cevapta client body'yi yarıda keser.

Header'lar gönderildikten sonra status değiştirmeye çalışmak. ERR_HTTP_HEADERS_SENT alırsın; önce res.headersSent'e bak.

Route sırasını unutmak. /api/orders/:id, /api/orders/export'tan önce kayıtlıysa export'a hiç ulaşılamaz.

Body'yi okumadan cevap verip bağlantıyı açık bırakmak. Node, okunmamış body'yi atmak için bağlantıyı açık tutup geri kalanını bekliyor. labs/10/unread-body.ts'te client 100.000 byte bildirip 1.000'ini gönderiyor, sunucu body'yi okumadan 413 dönüyor; ikinci koşuda cevaba connection: close ekleniyor:

HTTP/1.1 413 Payload Too Large | Date: Sat, 14 Nov 2026 18:59:58 GMT | Connection: keep-alive
connection still open after 1 s, the server waits for the rest of the body
connection closed
HTTP/1.1 413 Payload Too Large | connection: close | Date: Sat, 14 Nov 2026 18:59:59 GMT
connection closed

İlk durumda kötü niyetli bir client, 413 aldıktan sonra da gigabyte'larca veri göndermeye devam edebilir. Bookstore bu yüzden 413'te bağlantıyı kapatıyor.

Küçük Bir Deneme

★☆☆ labs/10/slowloris.ts'te client'ın header'ları bitirip ("\r\n\r\n") body'yi yavaş göndermesini sağla ve requestTimeout'u 3 saniyeye indir. Bu sefer hangi süre devreye giriyor?

★★☆ Router'a HEAD desteği ekle: GET route'u olan her path için HEAD aynı header'ları dönsün ama body göndermesin. 405 cevabındaki allow header'ı da güncellensin.

★★★ PUT /api/books/:isbn/cover endpoint'ini yaz: body'yi 2 MB sınırla stream olarak oku, 08'deki saveCover'a ver ve InvalidCoverError'ı 422'ye çevir. Sınırı aşan bir yükleme yarıda kesildiğinde diskte geçici dosya kalıyor mu?

Bir framework'ün bize ne kazandıracağını artık somut olarak biliyoruz. Bir sonraki yazıda Express 5'e geçiyoruz: routing, middleware zinciri, express.json(), Express 4'ten değişenler ve NODE_ENV=production'ın Express'te gerçekte neyi değiştirdiği.