Node.js Production Engineering 02 — HTTP từ Wire đến API Contract
Theo một HTTP request từ wire tới Node.js handler, rồi thiết kế API contract có giới hạn, timeout, cache, streaming, CORS và graceful shutdown rõ ràng.
Frontend nhận 502 sau 30 giây, gateway ghi 504, còn application log không có dòng lỗi nào. Ba tín hiệu có thể cùng mô tả một request: client đã timeout, proxy đóng upstream, trong khi Node vẫn làm việc vì contract không có deadline và cancellation rõ ràng. HTTP không chỉ là URL cộng JSON; nó là giao thức, dòng byte, cache semantics và hợp đồng giữa nhiều tầng độc lập.
Bài này dùng Node.js 24 LTS và core node:http để bóc từng lớp trước khi đưa Express vào. Sau khi đọc, bạn có thể:
- đọc một HTTP/1.1 message và phân biệt method semantics, status với representation;
- coi request/response là stream có giới hạn và backpressure;
- thiết kế error, retry, cache và idempotency thành API contract cho frontend;
- xử lý CORS, cookie, SSE, compression và conditional request có chủ đích;
- đặt timeout theo từng tầng và shutdown mà không cắt ngang request đang xử lý.
Kiến thức cần có: event loop, stream và AbortSignal ở Phần 1. Các snippet tập trung vào protocol; chỗ nào lược bớt validation hoặc storage sẽ được ghi rõ.
1. Giao thức HTTP
HTTP dùng mô hình request/response. Ví dụ dưới là HTTP/1.1 dạng text; HTTP/2 và HTTP/3 đóng khung nhị phân, còn body ở mọi phiên bản có thể chứa byte bất kỳ chứ không mặc định là text.
REQUEST RESPONSE
POST /api/users HTTP/1.1 HTTP/1.1 201 Created
Host: example.com Content-Type: application/json
Content-Type: application/json Content-Length: 38
Authorization: Bearer abc
{"id":1,"name":"Ann"}
{"name":"Ann"}
└ method └ path └ version └ status code └ reason
Một thông điệp có ba phần: dòng đầu, các header (metadata), một dòng trống, rồi body tùy chọn.
Method — và phân biệt safe/idempotent
| Method | Mục đích | An toàn | Idempotent |
|---|---|---|---|
| GET | đọc | yes | yes |
| HEAD | chỉ header | yes | yes |
| POST | tạo | no | no |
| PUT | thay toàn bộ | no | yes |
| PATCH | sửa một phần | no | no |
| DELETE | xóa | no | yes |
Safe = không đổi trạng thái server. Idempotent = làm hai lần cũng như một lần. Điều này quan trọng khi retry: client/proxy có thể an toàn thử lại request idempotent sau sự cố mạng, nhưng thử lại POST có thể tạo bản trùng.
Để một thao tác
POSTcó thể retry, client gửi idempotency key ổn định và server lưu kết quả theo key trong cùng ranh giới consistency với side effect. Chỉ “kiểm tra rồi làm” bằng cache rời không đủ nếu process chết giữa hai bước.
Status code — nhớ theo nhóm
2xx Success 200 OK · 201 Created · 202 Accepted · 204 No Content
3xx Redirect 301 Moved Permanently · 302 Found · 304 Not Modified · 307/308 (keep method)
4xx Client error 400 Bad Request · 401 Unauthorized · 403 Forbidden · 404 Not Found
405 Method Not Allowed · 409 Conflict · 422 Unprocessable · 429 Too Many Requests
5xx Server error 500 Internal · 502 Bad Gateway · 503 Service Unavailable · 504 Gateway Timeout
Status code là một phần hợp đồng của API. Request sai syntax có thể là 400; representation hợp lệ về syntax nhưng không thỏa validation có thể là 422; conflict với trạng thái hiện tại thường là 409. 401 yêu cầu thông tin xác thực hợp lệ và thường đi cùng WWW-Authenticate; 403 nghĩa là server hiểu request nhưng từ chối cấp quyền.
Nguyên tắc REST
- Tài nguyên là danh từ trong URL:
/users,/users/1/posts— không phải/getUsers. - Method HTTP là động từ.
- Không trạng thái — mỗi request mang đủ thứ cần; server không giữ session từng-client trong bộ nhớ giữa các request.
- Dùng đúng status + biểu diễn; hỗ trợ thương lượng nội dung qua
Accept.
2. Phiên bản HTTP & tái dùng kết nối
Độ trễ API còn phụ thuộc cách kết nối được thiết lập và tái sử dụng:
- HTTP/1.0 thường tạo kết nối ngắn nếu không thương lượng keep-alive; bắt tay TCP/TLS lặp lại làm tăng chi phí.
- HTTP/1.1 mặc định dùng kết nối persistent. Pipelining hiếm được dùng và response vẫn theo thứ tự trên một kết nối.
- HTTP/2 ghép nhiều stream trên một kết nối, dùng binary framing và HPACK. Nó giảm head-of-line blocking ở tầng HTTP nhưng các stream vẫn chia sẻ TCP.
- HTTP/3 chạy trên QUIC; mất packet của một stream không buộc stream khác chờ như trên một TCP connection.
import { createServer } from 'node:http'; // HTTP/1.1
import { createSecureServer } from 'node:http2'; // HTTP/2 (needs TLS in browsers)
Thực tế bạn thường chạy HTTP/1.1 thuần trong Node và để reverse proxy (Nginx) hoặc load balancer kết thúc TLS và nói HTTP/2/3 với trình duyệt. Dù sao, tái dùng kết nối outbound bằng keep-alive agent khi gọi service khác:
import { Agent } from 'node:http';
// A shared agent pools sockets — avoids a TCP+TLS handshake on every call.
const agent = new Agent({ keepAlive: true, maxSockets: 50 });
3. Server HTTP Node.js thô
import { createServer } from 'node:http';
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ message: 'Hello' }));
return;
}
res.statusCode = 404;
res.end('Not Found');
});
server.listen(3000, () => console.log('http://localhost:3000'));
Hai sự thật mở khóa mọi thứ về sau: req là Readable stream (body đến theo chunk) và res là Writable stream (bạn ghi status, header, rồi body).
Header trước body, một lần: một khi body bắt đầu, header bị khóa. Ghi lại ném
ERR_HTTP_HEADERS_SENT— lỗi người mới gặp nhiều nhất. Dùngres.writeHead(status, headers)để đặt cả hai cùng lúc.
Một helper response nhỏ loại bỏ lặp lại và bug thứ tự header:
import type { ServerResponse } from 'node:http';
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload), // bytes, not characters
});
res.end(payload);
}
4. Parse request
URL & query parameter
req.url chỉ là chuỗi. Parse bằng class URL:
// Với server origin cố định, không cần tin giá trị Host do client gửi.
const url = new URL(req.url ?? '/', 'http://internal.invalid');
url.pathname; // '/search'
url.searchParams.get('q'); // 'node'
url.searchParams.getAll('tag'); // ['a','b'] for ?tag=a&tag=b
Number(url.searchParams.get('page') ?? '1'); // strings → coerce yourself
Parse body JSON — kèm giới hạn
Body không được trao sẵn — gom chunk của stream, kèm chốt kích thước chống lạm dụng:
import type { IncomingMessage } from 'node:http';
async function readJson(
req: IncomingMessage,
maxBytes = 1_000_000
): Promise<unknown> {
const mediaType = req.headers['content-type']?.split(';', 1)[0]?.trim();
if (mediaType !== 'application/json') {
throw Object.assign(new Error('Content-Type must be application/json'), {
status: 415,
});
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
// req is async-iterable
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > maxBytes) {
throw Object.assign(new Error('Payload too large'), { status: 413 });
}
chunks.push(buffer);
}
if (size === 0)
throw Object.assign(new Error('JSON body is required'), { status: 400 });
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch {
throw Object.assign(new Error('Malformed JSON'), { status: 400 });
}
}
Parse JSON chỉ tạo ra unknown; nó không validate business schema. Handler vẫn phải kiểm tra shape bằng schema validator trước khi dùng. express.json() ở Phần 3 giải phần đọc/parse và giới hạn, không thay thế validation.
Form-urlencoded & multipart (upload file)
Form HTML gửi application/x-www-form-urlencoded; parse bằng URLSearchParams. Upload file dùng multipart/form-data, body bị chia bởi boundary thành nhiều phần. Không nên tự viết multipart parser; dùng thư viện đã xử lý boundary và stream như busboy:
import busboy from 'busboy';
import { createWriteStream } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { pipeline } from 'node:stream/promises';
function handleUpload(req: IncomingMessage): Promise<void> {
return new Promise((resolve, reject) => {
const writes: Promise<void>[] = [];
const bb = busboy({
headers: req.headers,
limits: { files: 1, parts: 10, fileSize: 10 * 1024 * 1024 },
});
bb.on('file', (_name, file, _info) => {
// Không dùng filename/extension của client làm storage path.
const target = join('uploads', `${randomUUID()}.upload`);
file.once('limit', () => {
file.destroy(
Object.assign(new Error('File too large'), { status: 413 })
);
});
writes.push(
pipeline(file, createWriteStream(target)).catch(async (error) => {
await rm(target, { force: true }); // dọn file dở/truncated
throw error;
})
);
});
bb.on(
'close',
() => void Promise.all(writes).then(() => resolve(), reject)
);
bb.on('error', reject);
bb.on('filesLimit', () =>
bb.destroy(Object.assign(new Error('Too many files'), { status: 413 }))
);
bb.on('partsLimit', () =>
bb.destroy(Object.assign(new Error('Too many parts'), { status: 413 }))
);
req.once('aborted', () =>
bb.destroy(new Error('Upload aborted by client'))
);
req.pipe(bb);
});
}
Với upload lớn, hãy stream ra object storage hoặc vùng tạm có quota thay vì buffer toàn bộ. Filename, media type và extension do client gửi đều không đáng tin; tạo storage key phía server, kiểm tra nội dung khi use case yêu cầu, và dọn file dở khi pipeline lỗi.
5. Tự dựng router
Vài lệnh if (method && url) không co giãn và không hỗ trợ path param. Dựng một matcher nhỏ và bạn hiểu chính xác Express làm gì:
import type { IncomingMessage, ServerResponse } from 'node:http';
type Params = Record<string, string>;
type Handler = (
req: IncomingMessage,
res: ServerResponse,
params: Params
) => void;
interface Route {
method: string;
pattern: RegExp;
keys: string[];
handler: Handler;
}
const routes: Route[] = [];
function add(method: string, path: string, handler: Handler): void {
const keys: string[] = [];
// '/users/:id' → /^\/users\/([^/]+)$/ capturing each :param
const pattern = new RegExp(
'^' +
path.replace(/:[^/]+/g, (m) => {
keys.push(m.slice(1));
return '([^/]+)';
}) +
'$'
);
routes.push({ method, pattern, keys, handler });
}
function route(req: IncomingMessage, res: ServerResponse): void {
const { pathname } = new URL(req.url ?? '/', 'http://internal.invalid');
const matches = routes.filter((r) => r.pattern.test(pathname));
if (matches.length === 0) {
res.writeHead(404).end('Not Found');
return;
}
const r = matches.find((m) => m.method === req.method);
if (!r) {
res.writeHead(405).end('Method Not Allowed');
return;
} // path exists, verb doesn't
const captured = r.pattern.exec(pathname)!.slice(1);
const params = Object.fromEntries(r.keys.map((k, i) => [k, captured[i]]));
r.handler(req, res, params);
}
add('GET', '/users/:id', (_req, res, params) => res.end(`user ${params.id}`));
Khi path tồn tại nhưng method không được hỗ trợ, trả 405 và nên kèm header Allow; 404 dành cho resource/path không tồn tại. Router minh họa cũng chưa escape ký tự regex trong static path, nên không dùng nguyên trạng cho input route động.
6. Trả response đúng — streaming & SSE
Vì res là Writable stream, bạn có thể stream response lớn với bộ nhớ phẳng và backpressure tự động:
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// Stream a file back — never read it fully into memory first
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
await pipeline(createReadStream('big.zip'), res);
Server-Sent Events (SSE) là luồng sự kiện một chiều trên một response HTTP sống lâu — hợp cho thông báo trực tiếp, tiến độ, hay output LLM theo từng token, không cần WebSocket:
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // hữu ích khi chạy sau Nginx
});
res.flushHeaders();
const timer = setInterval(() => {
// Với event dày, phải dừng/coalesce khi write() trả false để tôn trọng backpressure.
res.write(`event: tick\ndata: ${Date.now()}\n\n`);
}, 1000);
res.on('close', () => clearInterval(timer));
Response sống lâu phải dọn timer/subscription khi close. Production còn cần heartbeat comment, giới hạn số connection, proxy timeout phù hợp và cơ chế resume bằng event id/Last-Event-ID nếu không được phép mất sự kiện.
7. Cookie & session
Cookie là một chuỗi nhỏ server đặt và trình duyệt gửi lại ở mọi request sau — cách chuẩn để mang session id:
res.setHeader(
'Set-Cookie',
[
// The four attributes that matter for security:
`sid=${id}`,
'HttpOnly', // JS can't read it → blunts XSS token theft
'Secure', // HTTPS only
'SameSite=Lax', // giảm một số cross-site request; không thay thế CSRF defense
'Path=/; Max-Age=86400',
].join('; ')
);
// Reading cookies back:
const cookies = Object.fromEntries(
(req.headers.cookie ?? '')
.split('; ')
.filter(Boolean)
.map((c) => {
const i = c.indexOf('=');
return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))];
})
);
Không trạng thái vs có trạng thái: một id session + store phía server (Redis) là có trạng thái (dễ thu hồi); một JWT ký là không trạng thái (không tra cứu, nhưng khó thu hồi trước hạn). Phase 5 sẽ đào sâu cả hai.
8. CORS — từ gốc
Same-origin policy của trình duyệt chặn JS đọc response từ origin khác trừ khi server cho phép bằng header CORS. Với request “không đơn giản” trình duyệt gửi trước một preflight OPTIONS:
function applyCors(req: IncomingMessage, res: ServerResponse): boolean {
const allowedOrigin = 'https://app.example.com';
const origin = req.headers.origin;
res.setHeader('Vary', 'Origin');
if (origin === allowedOrigin) {
res.setHeader('Access-Control-Allow-Origin', allowedOrigin); // không dùng '*' với credentials
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Max-Age', '600');
if (req.method === 'OPTIONS') {
res.writeHead(origin === allowedOrigin ? 204 : 403).end();
return true; // handled
}
return false;
}
Trình duyệt thực thi CORS khi script muốn đọc cross-origin response;
curlhoặc service backend không chịu ràng buộc đó. Vì vậy CORS không thay authentication, authorization hay CSRF defense. Khi phản chiếu origin từ allowlist động, luôn thêmVary: Originđể cache không phục vụ nhầm policy.
9. Cache & request có điều kiện
Response rẻ nhất là cái bạn không phải gửi. Dùng Cache-Control để báo client/proxy nội dung còn tươi bao lâu, và ETag/Last-Modified để xác thực lại:
import { createHash } from 'node:crypto';
const body = JSON.stringify(data);
const etag = `"${createHash('sha256').update(body).digest('base64url')}"`;
if (req.headers['if-none-match'] === etag) {
res.writeHead(304).end(); // not modified — send nothing, save bandwidth
return;
}
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
ETag: etag,
'Cache-Control': 'public, max-age=60',
});
res.end(body);
public chỉ đúng khi representation không chứa dữ liệu riêng theo user. Với dữ liệu cá nhân, chọn private, no-cache (được lưu nhưng phải revalidate), hoặc no-store (không được lưu) theo threat model. Nếu response đổi theo Accept, Origin hay Accept-Encoding, khai báo Vary tương ứng.
10. Nén & thương lượng nội dung
Nếu client báo Accept-Encoding: gzip, br, nén luồng response:
import { createGzip, createBrotliCompress } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
// Minh họa đường dữ liệu; production nên dùng parser thương lượng có hỗ trợ q-value.
const accepts = req.headers['accept-encoding'] ?? '';
if (accepts.includes('br')) {
res.writeHead(200, { 'Content-Encoding': 'br', Vary: 'Accept-Encoding' });
await pipeline(source, createBrotliCompress(), res);
} else if (accepts.includes('gzip')) {
res.writeHead(200, { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' });
await pipeline(source, createGzip(), res);
} else {
await pipeline(source, res);
}
Đặt
Vary: Accept-Encodingđể cache lưu riêng từng representation. Đo trước khi nén payload nhỏ hoặc dữ liệu đã nén như JPEG/ZIP; CPU cost có thể lớn hơn số byte tiết kiệm. Reverse proxy/CDN thường là nơi phù hợp hơn để nén response tĩnh.
11. Header bảo mật
Một nhúm header response làm cứng app — đây là cái helmet đặt giúp bạn trong Express:
res.setHeader('X-Content-Type-Options', 'nosniff'); // don't MIME-sniff
res.setHeader('X-Frame-Options', 'DENY'); // defense cũ cho browser hỗ trợ
res.setHeader('Strict-Transport-Security', 'max-age=63072000'); // chỉ gửi trên HTTPS đã vận hành đúng
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; frame-ancestors 'none'"
);
res.setHeader('Referrer-Policy', 'no-referrer');
Header bảo mật không phải bộ giá trị copy-paste. CSP phải khớp resource graph thật và nên rollout bằng report-only trước; HSTS có tác động dài hạn nên chỉ bật sau khi toàn bộ subdomain trong scope phục vụ HTTPS ổn định. Phần 5 sẽ đặt chúng trong threat model đầy đủ.
12. Khái niệm middleware — khám phá, không phải import
Sau vài route, bạn nhận ra cùng nhu cầu khắp nơi: log, parse body, CORS, auth. Insight: một request nên chảy qua pipeline các hàm nhỏ, mỗi hàm có thể xem/sửa nó hoặc chuyển quyền đi tiếp.
request ─▶ [logger] ─▶ [cors] ─▶ [bodyParser] ─▶ [auth] ─▶ [handler] ─▶ response
│ │ │ │ │
next() next() next() next() res.end()
Dựng một phiên bản nhận biết async với xử lý lỗi tập trung:
import type { IncomingMessage, ServerResponse } from 'node:http';
type Middleware = (
req: IncomingMessage,
res: ServerResponse,
next: () => Promise<void>
) => void | Promise<void>;
function compose(middlewares: Middleware[]) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
let i = -1;
const dispatch = async (n: number): Promise<void> => {
if (n <= i) throw new Error('next() called multiple times');
i = n;
const mw = middlewares[n];
if (mw) await mw(req, res, () => dispatch(n + 1));
};
try {
await dispatch(0);
} catch (err) {
if (!res.headersSent) res.writeHead(500).end('Internal Server Error');
console.error(err);
}
};
}
Mẫu next() đó chính là trái tim của Express và Koa. Bạn vừa dựng một phiên bản nhỏ của nó.
13. Timeout & tắt êm
Server cần deadline chống client chậm và cần drain request đang chạy khi deploy. Ba timeout dưới đây đo các giai đoạn khác nhau; chọn số dựa trên kích thước upload, proxy timeout và SLO thay vì sao chép nguyên giá trị.
const server = createServer(handler);
// Defend against slow-loris-style attacks and hung sockets:
server.requestTimeout = 30_000; // max time to receive the full request
server.headersTimeout = 10_000; // max time to receive headers
server.keepAliveTimeout = 5_000; // idle keep-alive socket lifetime
server.listen(3000);
// Graceful shutdown: stop accepting, let active requests finish, then exit.
function shutdown(signal: string): void {
console.log(`${signal} received — draining...`);
server.close(() => {
console.log('closed');
process.exit(0);
});
server.closeIdleConnections();
// Sau grace period: đóng connection còn lại rồi thoát lỗi để orchestrator ghi nhận.
setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.close()dừng nhận connection mới và đợi connection hiện có đóng; trên Node.js hiện đại nó cũng xử lý idle keep-alive connection.closeIdleConnections()được gọi tường minh để ý đồ rõ ràng và tương thích baseline cũ hơn;closeAllConnections()chỉ là hard deadline cuối, vì nó có thể cắt request đang chạy.
14. Failure modes và hợp đồng với frontend
Frontend không nên suy luận lỗi từ chuỗi message. Hãy công bố một error envelope ổn định, ví dụ dựa trên Problem Details for HTTP APIs:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Request-Id: 01J...
{
"type": "https://api.example.com/problems/validation-error",
"title": "Request validation failed",
"status": 422,
"code": "TASK_TITLE_INVALID",
"requestId": "01J...",
"errors": [{ "path": "title", "reason": "must_not_be_empty" }]
}
code và path là machine-readable contract để client map sang UI; title/detail phục vụ log hoặc fallback, không phải khóa logic. Không trả stack trace, SQL error hoặc secret. Cùng requestId phải xuất hiện trong response và structured log để support lần ngược một failure.
Retry contract
| Tình huống | Client nên làm gì | Server phải cung cấp gì |
|---|---|---|
GET timeout trước khi nhận response | retry có backoff/jitter trong deadline tổng | operation idempotent, timeout rõ |
POST tạo resource mất response | chỉ retry với cùng idempotency key | lưu key, fingerprint request và response kết quả |
429 | chờ theo Retry-After, giới hạn tổng số lần | rate-limit policy nhất quán |
503 tạm thời | retry có budget nếu UX cho phép | Retry-After khi ước lượng được |
validation 4xx | sửa input, không retry tự động | field error ổn định |
Failure modes cần diễn tập
| Failure mode | Triệu chứng | Kiểm chứng |
|---|---|---|
| Body vượt giới hạn hoặc slow upload | memory/socket tăng, request treo | gửi chunk chậm và payload > limit; xác nhận 413/timeout |
| Client đóng giữa response stream | pipeline reject, file handle còn mở | hủy download; xác nhận cleanup và không có unhandled rejection |
| Proxy buffer SSE | event tới theo cụm thay vì realtime | test qua đúng Nginx/CDN production path |
Cache thiếu Vary | user/origin nhận nhầm representation | integration test với hai origin/encoding |
| Deploy trong long request | client bị reset hoặc process không thoát | gửi SIGTERM, đo drain deadline và exit code |
API contract không dừng ở OpenAPI schema. Nó gồm method semantics, status, header, body limit, timeout, retry, cache, versioning và hành vi khi request bị hủy.
15. Dự án thực hành
-
REST API không Express: dựng API
taskschỉ bằnghttp, router tự dựng (có xử lý405), classURL, vàreadJson. Trả đúng status code và hình dạng lỗi nhất quán. -
Bộ middleware production: với
compose, xếp lớp logger (quares.on('finish')), CORS, parser body JSON (413 khi quá cỡ), và header bảo mật. -
Endpoint streaming: thêm
GET /download/:file(stream file bằngpipeline),GET /events(đồng hồ SSE tự dọn khi ngắt), và thương lượng gzip/brotli.
Bài tập thêm: thêm ETag + 304 cho danh sách tasks và chứng minh request lặp tiết kiệm băng thông; thêm requestTimeout và tắt êm, xác nhận request đang chạy hoàn tất khi SIGTERM; thêm idempotency key cho POST /tasks, lưu cả fingerprint request và response để cùng key nhưng khác payload bị từ chối.
Tiêu chí hoàn thành: test phải bao phủ malformed JSON (400), sai content type (415), body quá lớn (413), method không hỗ trợ (405 + Allow), conditional GET (304 không body), CORS từ origin bị từ chối, client hủy stream và shutdown khi đang có request. Ghi lại curl command cùng expected status/header cho từng case.
16. Checklist trước khi ship
- Trả đúng status code.
- Giới hạn body và stream upload ra đĩa.
- Đặt cookie an toàn và header bảo mật.
- Xử lý preflight CORS đúng và biết nó do trình duyệt thực thi.
- Thêm cache/ETag khi hữu ích và dọn response sống lâu.
- Đặt timeout và tắt êm khi
SIGTERM. - Error body có schema, code ổn định và request id.
- Retry policy của client khớp idempotency semantics của endpoint.
- Cache policy phân biệt public với dữ liệu cá nhân và khai báo
Varyđúng.
Nếu chỉ nhớ năm điều
- HTTP semantics là contract; JSON chỉ là một representation.
- Parse không đồng nghĩa với validate, và mọi input stream đều cần giới hạn.
- Timeout phải được thiết kế xuyên client, proxy, server và dependency.
- CORS là browser read policy, không phải authentication hay CSRF defense.
- Retry, idempotency, cache và error envelope cần được thiết kế cùng frontend từ đầu.
Phần tiếp theo
Bạn hiểu HTTP ở mức giao thức, đã dựng server thật bằng http thô — routing có param, streaming, SSE, cookie, CORS, cache, nén, header bảo mật, timeout và tắt êm — và đã tự dựng khái niệm middleware.
Ở Phần 3, ta đưa Express 5 vào để chuẩn hóa request pipeline, routing, validation và error boundary — nhưng vẫn giữ nguyên các invariant về stream, timeout và contract vừa xây.
Đọc tiếp: Express 5 và Request Pipeline.