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.
Đây là Phần 5 của series 10 bài về lập trình mạng với Node.js + TypeScript. Phần 1–4 đã cho bạn tầng, TCP, UDP và DNS. Giờ ta bóc tách HTTP — giao thức đằng sau hầu hết API bạn gọi.
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. Bài học hôm nay: HTTP chỉ là định dạng text được đóng khung trên luồng byte TCP.
HTTP là text trên socket TCP
Ở 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. 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.
Một message HTTP có ba phần:
- Dòng bắt đầu — request line hoặc status line.
- Header — dòng
Key: value, mỗi dòng một cặp. - Body (tùy chọn) — byte thô sau dòng trống tách header khỏi body.
Mỗi dòng trong phần header kết thúc bằng \r\n — không chỉ \n. Dòng trống trước body thực chất là \r\n sau header cuối.
Đâ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"]}
Ý chính: 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.
Cấu trúc request
| Phần | Định dạng | 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 |
Request line có đúng ba token: method, path (kèm query string), và version. Method phổ biến: GET …
Header mang metadata: Host, Content-Type, Content-Length, Authorization, Cookie, và hàng trăm cái khác. Server dùng chúng để định tuyến, xác thực, và quyết định cách đọc body.
Cấu trúc response
| Phần | Định dạng | 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 |
Mã trạng thái là số ba chữ số: 2xx thành công, 3xx chuyển hướng, 4xx lỗi client, 5xx lỗi server. Reason phrase mang tính trang trí — client dựa vào mã số.
Tự viết HTTP trên socket TCP thô
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. 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}`);
});
Chạy và thử:
node raw-http-server.js
curl -v http://localhost:3000/anything
curl giờ nhận response đúng định dạng — status line, header, dòng trống, body JSON. Bạn viết HTTP mà không import http.
Chú ý ba chi tiết quan trọng trong chuỗi response:
- Mỗi dòng header cách nhau bằng
\r\n, không chỉ\n. - Chuỗi rỗng trong join tạo dòng trống bắt buộc trước body.
Content-Lengthkhớp độ dài byte chính xác của body — dùngBuffer.byteLength, không phảistring.lengthvới non-ASCII.
Đây là để học, không phải production. 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.
Cách đúng: node:http createServer
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}`);
});
Object quan trọng:
- …
- …
writeHead gửi status line và header; end kết thúc body và đóng response (trừ khi keep-alive). Node tự đặt Content-Length khi bạn truyền string hoặc Buffer cho end().
Thử với curl:
curl http://localhost:3000/health
curl http://localhost:3000/users
curl -i http://localhost:3000/missing # → 404
Đọc body của request
Request GET thường không có body. POST và PUT gửi dữ liệu trong body — và req là readable stream, không phải string. 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);
Thử:
curl -X POST http://localhost:3001/echo \
-H 'Content-Type: text/plain' \
-d 'hello from curl'
# → {"echo":"hello from curl"}
Vì sao phải đọc hết body: 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. 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.
Client HTTP nhỏ
Bạn có thể gọi server bằng fetch (Node 18+) hoặc http.request cấp thấp hơn.
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 resolve khi header đến; .json() đọc luồng body. 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());
Với 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);
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
Bên nhận biết body kết thúc ở đâu? HTTP/1.1 có hai chiến lược chính:
| Chiến lược | Header | Cách hoạt động |
|---|---|---|
| Fixed length | Content-Length: N | Đọc đúng N byte sau dòng trống |
| Chunked | Transfer-Encoding: chunked | 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 đơn giản và nhanh khi biết trước kích thước. Ví dụ socket thô dùng nó; res.end(string) tự đặt.
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 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
Bạn hiếm khi tự đặt Transfer-Encoding: chunked — stack HTTP của Node lo framing. Nhưng biết khác biệt giải thích output curl và hành vi buffer của proxy.
Keep-alive: một TCP, nhiều request
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. Đây là keep-alive — nhiều cặp request/response trên một socket TCP.
Vì sao quan trọng:
- Chi phí bắt tay TCP (Phần 2) trả một lần, không phải mỗi request.
- Browser mở ~6 kết nối song song mỗi host; keep-alive lấp đầy bằng request tuần tự.
- Làm sai — body chưa đọc, framing sai — làm hỏng kết nối cho mọi request sau.
createServer của Node xử lý keep-alive tự động khi client hỗ trợ. Để ép đóng sau mỗi response (như demo socket thô):
res.writeHead(200, {
'Content-Type': 'text/plain',
'Connection': 'close',
});
res.end('goodbye');
| Chế độ | Header | Hành vi |
|---|---|---|
| Keep-alive (HTTP/1.1 default) | Connection: keep-alive (often implicit) | Socket mở cho request tiếp |
| Close | Connection: close | Server đóng TCP sau response này |
Đừng giả định một kết nối TCP bằng một request. Trên server bận, cùng socket có thể mang hàng chục message HTTP liên tiếp.
Lỗi người mới hay mắc
- 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. - 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. Content-Lengthsai hoặc thiếu — client treo chờ thêm byte, hoặc cắt sớm.- 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.
- 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.
Bài tập
Thử từng bài trước khi mở lời giải.
- Mở rộng responder TCP thô để log dòng đầu mỗi request đến (parse
METHOD path HTTP/1.1từ\r\nđầu). - Thêm route
POST /logincho ví dụcreateServerđọc JSON\{ username, password \}và trả200với\{ token: 'abc' \}hoặc401nếu thiếu field. - Chạy
curl -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).
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 outputBà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.
Điều cốt lõi
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. 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. Đọ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. Đã bóc tách HTTP, Phần 6 sẽ phủ TLS lên cùng socket.