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 request và response 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}.
A single HTTP message has three parts {Một message HTTP có ba phần}:
- 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}. - Headers —
Key: valuelines, one per line {Header — dòngKey: value, mỗi dòng một cặp}. - 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:httpjust automates the parsing and formatting {nếu bạn ghi text hợp lệ vào socket TCP, bạn đã có server HTTP —node:httpchỉ 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 line | METHOD SP path SP HTTP/version | GET /health HTTP/1.1 |
| Headers | name: value + \r\n | Host: localhost:3000 |
| Blank line | \r\n | separates headers from body |
| Body | raw 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 line | HTTP/version SP code SP reason | HTTP/1.1 404 Not Found |
| Headers | name: value + \r\n | Content-Type: text/plain |
| Blank line | \r\n | end of headers |
| Body | raw bytes | HTML, 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\nalone {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-Lengthmatches the exact byte length of the body —Buffer.byteLength, notstring.lengthfor non-ASCII {Content-Lengthkhớp độ dài byte chính xác của body — dùngBuffer.byteLength, không phảistring.lengthvớ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: IncomingMessage—req.method(GET,POST, …),req.url(path + query),req.headers(lowercased keys) {…}.res: ServerResponse—res.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 {POST và PUT 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' và '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} | Header | How it works {Cách hoạt động} |
|---|---|---|
| Fixed length | Content-Length: N | Read exactly N bytes after the blank line {Đọc đúng N byte sau dòng trống} |
| Chunked | Transfer-Encoding: chunked | Body 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ế độ} | Header | Behavior {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} |
| Close | Connection: close | Server 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
\ninstead of\r\nfor line endings in raw HTTP — strict parsers (and curl) may fail or misread headers {Dùng\nthay vì\r\ncho 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-Lengthsai 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}.
- Extend the raw TCP responder to log the first line of each incoming request (parse
METHOD path HTTP/1.1from the first\r\n) {Mở rộng responder TCP thô để log dòng đầu mỗi request đến (parseMETHOD path HTTP/1.1từ\r\nđầu)}. - Add a
POST /loginroute to thecreateServerexample that reads JSON\{ username, password \}and returns200with\{ token: 'abc' \}or401if fields are missing {Thêm routePOST /logincho ví dụcreateServerđọc JSON\{ username, password \}và trả200với\{ token: 'abc' \}hoặc401nếu thiếu field}. - Run
curl -v --http1.1 http://localhost:3000/healthtwice in a row against a server withoutConnection: closeand note whether the TCP connection is reused (look for “Re-used connection” in verbose output) {Chạycurl -v --http1.1hai lần liên tiếp với server không cóConnection: closevà 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 outputExercise 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}.