jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Network Programming · Part 5 — HTTP From the Socket Up

HTTP is text over TCP: raw request/response anatomy, a minimal socket responder, node:http createServer, reading bodies, Content-Length vs chunked, keep-alive — bilingual with runnable TypeScript examples.

This is Part 5 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 5 của series 10 bài về lập trình mạng với Node.js + TypeScript}. Parts 1–4 gave you layers, TCP, UDP, and DNS {Phần 1–4 đã cho bạn tầng, TCP, UDP và DNS}. Now we peel back the curtain on HTTP — the protocol behind almost every API you call {Giờ ta bóc tách HTTP — giao thức đằng sau hầu hết API bạn gọi}.

Remember Part 1’s curl -v http://localhost:3000 against a plain TCP echo server? curl spoke HTTP, but the server replied with raw text — curl complained the response was malformed {Nhớ curl -v ở Phần 1 với server TCP thường? curl nói HTTP, nhưng server trả text thô — curl phàn nàn response sai định dạng}. That experiment was a preview of today’s lesson: HTTP is just a text format framed over a TCP byte stream {Bài học hôm nay: HTTP chỉ là định dạng text được đóng khung trên luồng byte TCP}.


HTTP is text on a TCP socket {HTTP là text trên socket TCP}

At the Application layer (Part 1), HTTP defines how client and server exchange requests and responses as human-readable lines of text {Ở tầng Application (Phần 1), HTTP định nghĩa cách client và server trao đổi requestresponse bằng dòng text đọc được}. Under the hood it is still Part 2’s reliable byte stream — no magic, just conventions {Bên dưới vẫn là luồng byte tin cậy Phần 2 — không phép màu, chỉ quy ước}.

Client Server GET /users HTTP/1.1 Host: api.example.com · headers · body HTTP/1.1 200 OK Content-Type · headers · JSON body request/response text framed over one TCP connection (keep-alive reuses it)
A request goes out as text; the server replies with text — all over one TCP connection (keep-alive can reuse it)

A single HTTP message has three parts {Một message HTTP có ba phần}:

  1. Start line — request line (METHOD path HTTP/1.1) or status line (HTTP/1.1 200 OK) {Dòng bắt đầu — request line hoặc status line}.
  2. HeadersKey: value lines, one per line {Header — dòng Key: value, mỗi dòng một cặp}.
  3. Body (optional) — raw bytes after a blank line that separates headers from body {Body (tùy chọn) — byte thô sau dòng trống tách header khỏi body}.

Every line in the header section ends with \r\n (carriage return + line feed) — not just \n {Mỗi dòng trong phần header kết thúc bằng \r\n — không chỉ \n}. The blank line before the body is literally \r\n after the last header {Dòng trống trước body thực chất là \r\n sau header cuối}.

Here is a complete raw exchange captured from curl -v {Đây là trao đổi thô hoàn chỉnh từ curl -v}:

→ REQUEST (client → server)
GET /users HTTP/1.1\r\n
Host: api.example.com\r\n
Accept: application/json\r\n
Connection: keep-alive\r\n
\r\n

← RESPONSE (server → client)
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 18\r\n
Connection: keep-alive\r\n
\r\n
{"users":["alice"]}

Key idea {Ý chính}: if you can write valid text to a TCP socket, you have a working HTTP server — node:http just automates the parsing and formatting {nếu bạn ghi text hợp lệ vào socket TCP, bạn đã có server HTTP — node:http chỉ tự động parse và format}.


Anatomy of a request {Cấu trúc request}

Part {Phần}Format {Định dạng}Example {Ví dụ}
Request lineMETHOD SP path SP HTTP/versionGET /health HTTP/1.1
Headersname: value + \r\nHost: localhost:3000
Blank line\r\nseparates headers from body
Bodyraw bytes (optional)JSON, form data, file upload

The request line has exactly three tokens: method, path (including query string), and version {Request line có đúng ba token: method, path (kèm query string), và version}. Common methods {Method phổ biến}: GET (read), POST (create/submit), PUT/PATCH (update), DELETE (remove) {…}.

Headers carry metadata {Header mang metadata}: Host (required in HTTP/1.1), Content-Type, Content-Length, Authorization, Cookie, and hundreds more {Host, Content-Type, Content-Length, Authorization, Cookie, và hàng trăm cái khác}. The server uses them to route, authenticate, and decide how to read the body {Server dùng chúng để định tuyến, xác thực, và quyết định cách đọc body}.


Anatomy of a response {Cấu trúc response}

Part {Phần}Format {Định dạng}Example {Ví dụ}
Status lineHTTP/version SP code SP reasonHTTP/1.1 404 Not Found
Headersname: value + \r\nContent-Type: text/plain
Blank line\r\nend of headers
Bodyraw bytesHTML, JSON, empty for 204

The status code is a three-digit number {Mã trạng thái là số ba chữ số}: 2xx success, 3xx redirect, 4xx client error, 5xx server error {2xx thành công, 3xx chuyển hướng, 4xx lỗi client, 5xx lỗi server}. The reason phrase (OK, Not Found) is cosmetic — clients rely on the numeric code {Reason phrase mang tính trang trí — client dựa vào mã số}.


Building HTTP by hand on a raw TCP socket {Tự viết HTTP trên socket TCP thô}

Before reaching for node:http, let’s prove HTTP is “just text” by writing a minimal responder with node:net from Part 2 {Trước khi dùng node:http, hãy chứng minh HTTP “chỉ là text” bằng responder tối giản với node:net Phần 2}. We read whatever arrives, ignore parsing for now, and write a valid HTTP response back {Ta đọc bất cứ gì đến, bỏ qua parse, và ghi response HTTP hợp lệ lại}.

import { createServer } from 'node:net';

const PORT = 3000;
const BODY = '{"ok":true,"message":"hello from raw socket"}';
const BODY_BYTES = Buffer.byteLength(BODY, 'utf8');

const server = createServer((socket) => {
  socket.on('data', () => {
    // In production you'd parse the request — here we just reply.
    const response = [
      'HTTP/1.1 200 OK',
      'Content-Type: application/json',
      `Content-Length: ${BODY_BYTES}`,
      'Connection: close',
      '',
      BODY,
    ].join('\r\n');

    socket.write(response);
    socket.end();
  });
});

server.listen(PORT, () => {
  console.log(`raw HTTP responder on http://localhost:${PORT}`);
});

Run it and test {Chạy và thử}:

node raw-http-server.js
curl -v http://localhost:3000/anything

curl now gets a well-formed response — status line, headers, blank line, JSON body {curl giờ nhận response đúng định dạng — status line, header, dòng trống, body JSON}. You wrote HTTP without importing http at all {Bạn viết HTTP mà không import http}.

Notice three critical details in the response string {Chú ý ba chi tiết quan trọng trong chuỗi response}:

  • Every header line is separated by \r\n, not \n alone {Mỗi dòng header cách nhau bằng \r\n, không chỉ \n}.
  • An empty string in the join produces the required blank line before the body {Chuỗi rỗng trong join tạo dòng trống bắt buộc trước body}.
  • Content-Length matches the exact byte length of the body — Buffer.byteLength, not string.length for non-ASCII {Content-Length khớp độ dài byte chính xác của body — dùng Buffer.byteLength, không phải string.length với non-ASCII}.

This is educational, not production-ready {Đây là để học, không phải production}. Real servers must parse incoming requests, handle partial reads, and support keep-alive — which is exactly what node:http does for you {Server thật phải parse request đến, xử lý đọc một phần, và hỗ trợ keep-alive — đúng những gì node:http làm giúp bạn}.


The real way: node:http createServer {Cách đúng: node:http createServer}

Node’s built-in node:http module parses requests and formats responses so you work with objects instead of raw bytes {Module node:http parse request và format response để bạn làm việc với object thay vì byte thô}.

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';

const PORT = 3000;

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  console.log(`${req.method} ${req.url}`);
  console.log('headers:', req.headers);

  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok' }));
    return;
  }

  if (req.method === 'GET' && req.url === '/users') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: ['alice', 'bob'] }));
    return;
  }

  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');
});

server.listen(PORT, () => {
  console.log(`http server on http://localhost:${PORT}`);
});

Key objects {Object quan trọng}:

  • req: IncomingMessagereq.method (GET, POST, …), req.url (path + query), req.headers (lowercased keys) {…}.
  • res: ServerResponseres.writeHead(status, headers), res.write(chunk), res.end(body) {…}.

writeHead sends the status line and headers; end finishes the body and closes the response (unless keep-alive) {writeHead gửi status line và header; end kết thúc body và đóng response (trừ khi keep-alive)}. Node sets Content-Length automatically when you pass a string or Buffer to end() {Node tự đặt Content-Length khi bạn truyền string hoặc Buffer cho end()}.

Test with curl {Thử với curl}:

curl http://localhost:3000/health
curl http://localhost:3000/users
curl -i http://localhost:3000/missing   # → 404

Reading a request body {Đọc body của request}

GET requests usually have no body {Request GET thường không có body}. POST and PUT send data in the body — and req is a readable stream, not a string {POSTPUT gửi dữ liệu trong body — và req là readable stream, không phải string}. You must listen for 'data' and 'end' (or use async iteration) {Bạn phải lắng nghe 'data''end' (hoặc dùng async iteration)}.

import { createServer } from 'node:http';

function readBody(req: import('node:http').IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];

    req.on('data', (chunk: Buffer) => {
      chunks.push(chunk);
    });

    req.on('end', () => {
      resolve(Buffer.concat(chunks).toString('utf8'));
    });

    req.on('error', reject);
  });
}

const server = createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/echo') {
    const body = await readBody(req);
    console.log('received body:', body);

    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ echo: body }));
    return;
  }

  res.writeHead(405, { 'Content-Type': 'text/plain' });
  res.end('Method Not Allowed');
});

server.listen(3001);

Test {Thử}:

curl -X POST http://localhost:3001/echo \
  -H 'Content-Type: text/plain' \
  -d 'hello from curl'
# → {"echo":"hello from curl"}

Why you must consume the body {Vì sao phải đọc hết body}: on a keep-alive connection, leftover bytes from an unread body will corrupt the next request on the same socket {trên kết nối keep-alive, byte thừa từ body chưa đọc sẽ làm hỏng request tiếp theo trên cùng socket}. Always drain or parse the body before handling another request on that connection {Luôn đọc hết hoặc parse body trước khi xử lý request khác trên cùng kết nối}.


A tiny HTTP client {Client HTTP nhỏ}

You can call your server with fetch (Node 18+) or the lower-level http.request {Bạn có thể gọi server bằng fetch (Node 18+) hoặc http.request cấp thấp hơn}.

With fetch {Với fetch}

const res = await fetch('http://localhost:3000/users');
console.log(res.status, res.statusText);
console.log('content-type:', res.headers.get('content-type'));
const data: unknown = await res.json();
console.log(data);

fetch resolves when headers arrive; .json() reads the body stream {fetch resolve khi header đến; .json() đọc luồng body}. For POST {Cho POST}:

const res = await fetch('http://localhost:3001/echo', {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },
  body: 'hello from fetch',
});
console.log(await res.json());

With http.request {Với http.request}

When you need fine control (custom headers, streaming upload, raw socket access), use http.request {Khi cần kiểm soát chi tiết, dùng http.request}:

import { request } from 'node:http';

function get(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const req = request(url, { method: 'GET' }, (res) => {
      const chunks: Buffer[] = [];
      res.on('data', (chunk: Buffer) => chunks.push(chunk));
      res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    });
    req.on('error', reject);
    req.end();
  });
}

const body = await get('http://localhost:3000/health');
console.log(body);

Under the hood, fetch in Node uses http/https anyway — same TCP connection, same text protocol {Bên trong, fetch trong Node vẫn dùng http/https — cùng kết nối TCP, cùng giao thức text}.


Content-Length vs chunked transfer-encoding {Content-Length vs chunked}

How does the receiver know where the body ends? HTTP/1.1 offers two main strategies {Bên nhận biết body kết thúc ở đâu? HTTP/1.1 có hai chiến lược chính}:

Strategy {Chiến lược}HeaderHow it works {Cách hoạt động}
Fixed lengthContent-Length: NRead exactly N bytes after the blank line {Đọc đúng N byte sau dòng trống}
ChunkedTransfer-Encoding: chunkedBody split into chunks; each chunk prefixed with its size in hex; ends with 0\r\n\r\n {Body chia chunk; mỗi chunk có kích thước hex; kết thúc bằng 0\r\n\r\n}

Content-Length is simple and fast when you know the size upfront {Content-Length đơn giản và nhanh khi biết trước kích thước}. Our raw socket example used it; res.end(string) sets it automatically {Ví dụ socket thô dùng nó; res.end(string) tự đặt}.

Chunked encoding is used when the server does not know the final size yet — e.g. streaming a large file or SSR HTML as it is generated {Chunked dùng khi server chưa biết kích thước cuối — vd stream file lớn hoặc SSR HTML khi đang tạo}. Node enables chunked mode when you call res.write() multiple times without setting Content-Length {Node bật chunked khi gọi res.write() nhiều lần mà không đặt Content-Length}:

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  if (req.url === '/stream') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.write('chunk one\n');
    res.write('chunk two\n');
    res.end('chunk three\n');
    return;
  }
  res.writeHead(404).end();
});

server.listen(3002);
curl -v http://localhost:3002/stream
# Transfer-Encoding: chunked
# each piece arrives as a separate chunk on the wire

You rarely set Transfer-Encoding: chunked manually — Node’s HTTP stack handles framing {Bạn hiếm khi tự đặt Transfer-Encoding: chunked — stack HTTP của Node lo framing}. But knowing the difference explains curl output and proxy buffering behavior {Nhưng biết khác biệt giải thích output curl và hành vi buffer của proxy}.


Keep-alive: one TCP connection, many requests {Keep-alive: một TCP, nhiều request}

By default, HTTP/1.1 assumes connections are reused unless either side sends Connection: close {Mặc định, HTTP/1.1 giả định kết nối được tái sử dụng trừ khi một bên gửi Connection: close}. This is keep-alive — multiple request/response pairs on a single TCP socket {Đây là keep-alive — nhiều cặp request/response trên một socket TCP}.

Why it matters {Vì sao quan trọng}:

  • TCP handshake cost (Part 2) is paid once, not per request {Chi phí bắt tay TCP (Phần 2) trả một lần, không phải mỗi request}.
  • Browsers open ~6 parallel connections per host; keep-alive fills each with sequential requests {Browser mở ~6 kết nối song song mỗi host; keep-alive lấp đầy bằng request tuần tự}.
  • Getting it wrong — unread body, malformed framing — poisons the connection for every subsequent request {Làm sai — body chưa đọc, framing sai — làm hỏng kết nối cho mọi request sau}.

Node’s createServer handles keep-alive automatically when clients support it {createServer của Node xử lý keep-alive tự động khi client hỗ trợ}. To force close after each response (like our raw socket demo) {Để ép đóng sau mỗi response (như demo socket thô)}:

res.writeHead(200, {
  'Content-Type': 'text/plain',
  'Connection': 'close',
});
res.end('goodbye');
Connection mode {Chế độ}HeaderBehavior {Hành vi}
Keep-alive (HTTP/1.1 default)Connection: keep-alive (often implicit)Socket stays open for more requests {Socket mở cho request tiếp}
CloseConnection: closeServer closes TCP after this response {Server đóng TCP sau response này}

Do not assume one TCP connection equals one request {Đừng giả định một kết nối TCP bằng một request}. On a busy server, the same socket may carry dozens of back-to-back HTTP messages {Trên server bận, cùng socket có thể mang hàng chục message HTTP liên tiếp}.


Mistakes beginners make {Lỗi người mới hay mắc}

  • ❌ Using \n instead of \r\n for line endings in raw HTTP — strict parsers (and curl) may fail or misread headers {Dùng \n thay vì \r\n cho dòng trong HTTP thô — parser nghiêm (và curl) có thể lỗi hoặc đọc sai header}.
  • ❌ Forgetting the blank line (\r\n) between headers and body — the client never finds the body start {Quên dòng trống (\r\n) giữa header và body — client không tìm được điểm bắt đầu body}.
  • ❌ Wrong or missing Content-Length — client hangs waiting for more bytes, or truncates early {Content-Length sai hoặc thiếu — client treo chờ thêm byte, hoặc cắt sớm}.
  • Not consuming the request body on POST/PUT — breaks keep-alive and corrupts the next request on the same socket {Không đọc hết body request với POST/PUT — phá keep-alive và làm hỏng request tiếp trên cùng socket}.
  • ❌ Assuming one TCP connection = one request — HTTP/1.1 keep-alive reuses the socket; you must finish one message completely before the next begins {Giả định một TCP = một request — keep-alive HTTP/1.1 tái dùng socket; phải hoàn tất một message trước khi message tiếp bắt đầu}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Extend the raw TCP responder to log the first line of each incoming request (parse METHOD path HTTP/1.1 from the first \r\n) {Mở rộng responder TCP thô để log dòng đầu mỗi request đến (parse METHOD path HTTP/1.1 từ \r\n đầu)}.
  2. Add a POST /login route to the createServer example that reads JSON \{ username, password \} and returns 200 with \{ token: 'abc' \} or 401 if fields are missing {Thêm route POST /login cho ví dụ createServer đọc JSON \{ username, password \} và trả 200 với \{ token: 'abc' \} hoặc 401 nếu thiếu field}.
  3. Run curl -v --http1.1 http://localhost:3000/health twice in a row against a server without Connection: close and note whether the TCP connection is reused (look for “Re-used connection” in verbose output) {Chạy curl -v --http1.1 hai lần liên tiếp với server khôngConnection: close và xem TCP có được tái dùng không (tìm “Re-used connection” trong output verbose)}.
Solution {Lời giải}
// 1 — raw TCP: parse first line
import { createServer } from 'node:net';

const BODY = '{"ok":true}';
const LEN = Buffer.byteLength(BODY, 'utf8');

createServer((socket) => {
  let buffer = '';

  socket.on('data', (chunk: Buffer) => {
    buffer += chunk.toString('utf8');
    const headerEnd = buffer.indexOf('\r\n\r\n');
    if (headerEnd === -1) return;

    const firstLine = buffer.split('\r\n')[0] ?? '';
    const [method, path, version] = firstLine.split(' ');
    console.log(`request: ${method} ${path} ${version}`);

    const response = [
      'HTTP/1.1 200 OK',
      'Content-Type: application/json',
      `Content-Length: ${LEN}`,
      'Connection: close',
      '',
      BODY,
    ].join('\r\n');

    socket.write(response);
    socket.end();
  });
}).listen(3000);
// 2 — POST /login with JSON body
import { createServer } from 'node:http';

function readBody(req: import('node:http').IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', (c: Buffer) => chunks.push(c));
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    req.on('error', reject);
  });
}

createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/login') {
    try {
      const raw = await readBody(req);
      const parsed: unknown = JSON.parse(raw);
      if (
        typeof parsed === 'object' &&
        parsed !== null &&
        'username' in parsed &&
        'password' in parsed &&
        typeof (parsed as { username: unknown }).username === 'string' &&
        typeof (parsed as { password: unknown }).password === 'string'
      ) {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ token: 'abc' }));
        return;
      }
    } catch {
      // fall through to 401
    }
    res.writeHead(401, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'missing credentials' }));
    return;
  }
  res.writeHead(404).end();
}).listen(3001);
# 3 — observe keep-alive reuse
node http-server.js   # createServer without Connection: close
curl -v --http1.1 http://localhost:3000/health  # first request: new connection
curl -v --http1.1 http://localhost:3000/health  # second: "Re-used connection" in -v output

Exercise 3 confirms that HTTP/1.1 default keep-alive avoids a new TCP handshake on every call — the same socket serves both requests {Bài 3 xác nhận keep-alive mặc định HTTP/1.1 tránh bắt tay TCP mới mỗi lần gọi — cùng socket phục vụ cả hai request}.


Takeaway {Điều cốt lõi}

HTTP is a text protocol framed over Part 2’s TCP byte stream: request line or status line, headers, blank line, optional body — all with \r\n line endings {HTTP là giao thức text trên luồng byte TCP Phần 2: request line hoặc status line, header, dòng trống, body tùy chọn — tất cả dùng \r\n}. You can write a valid server with raw socket.write(), but node:http parses requests (req.method, req.url, req.headers), formats responses (writeHead, end), and handles Content-Length, chunked encoding, and keep-alive {Bạn có thể viết server hợp lệ bằng socket.write() thô, nhưng node:http parse request, format response, và xử lý Content-Length, chunked, keep-alive}. Consume every request body, get Content-Length right, and never assume one connection carries only one message {Đọc hết mọi body request, đặt Content-Length đúng, và đừng giả định một kết nối chỉ mang một message}. With HTTP demystified, Part 6 can layer TLS on top of the same socket {Đã bóc tách HTTP, Phần 6 sẽ phủ TLS lên cùng socket}.