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.

Đâ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 requestresponse 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.

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)

Một message HTTP có ba phần:

  1. Dòng bắt đầu — request line hoặc status line.
  2. Header — dòng Key: value, mỗi dòng một cặp.
  3. 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:http chỉ tự động parse và format.


Cấu trúc request

PhầnĐịnh dạngVí 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

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ạngVí 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

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-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.

Đâ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. POSTPUT 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''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ượcHeaderCách hoạt động
Fixed lengthContent-Length: NĐọc đúng N byte sau dòng trống
ChunkedTransfer-Encoding: chunkedBody 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ế độHeaderHành vi
Keep-alive (HTTP/1.1 default)Connection: keep-alive (often implicit)Socket mở cho request tiếp
CloseConnection: closeServer đó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 \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.
  • 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-Length sai 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.

  1. 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. 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. 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).
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

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.


Đ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.