jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Network Programming · Part 2 — TCP Sockets: A Reliable Byte Stream

Open real TCP sockets in Node.js: the three-way handshake, echo server + client with createServer/createConnection, socket events, graceful close, and common pitfalls — bilingual with exercises.

This is Part 2 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 2 của series 10 bài về lập trình mạng với Node.js + TypeScript}. Part 1 gave you the layered mental model; now we open a real TCP socket and move bytes between two programs {Phần 1 đã cho bạn mô hình phân tầng; giờ ta mở socket TCP thật và truyền byte giữa hai chương trình}.

TCP is the workhorse of the internet — HTTP, WebSockets, and most databases ride on top of it {TCP là công cụ chủ lực của internet — HTTP, WebSocket, và hầu hết database chạy trên nó}. Understanding sockets directly makes every higher-level protocol easier to debug {Hiểu socket trực tiếp giúp debug mọi giao thức tầng cao dễ hơn}.


What TCP gives you {TCP mang lại gì}

TCP sits in the Transport layer (Part 1) and offers three guarantees {TCP nằm ở tầng Transport (Phần 1) và đưa ra ba đảm bảo}:

  • Connection-oriented — both sides agree the link exists before data flows {Hướng kết nối — hai bên đồng ý liên kết tồn tại trước khi dữ liệu chảy}.
  • Reliable — lost packets are retransmitted; duplicates are dropped {Tin cậy — packet mất được gửi lại; bản trùng bị loại}.
  • Ordered byte stream — bytes arrive in the order sent, as one continuous stream {Luồng byte có thứ tự — byte đến đúng thứ tự gửi, thành một luồng liên tục}.

What TCP does not give you: message boundaries {TCP không cung cấp: ranh giới message}. If you send "HELLO" then "WORLD", the receiver might get "HEL" + "LOWORLD" in two 'data' events — same bytes, different chunking {Nếu bạn gửi "HELLO" rồi "WORLD", bên nhận có thể nhận "HEL" + "LOWORLD" trong hai sự kiện 'data' — cùng byte, khác cách cắt}. We fix that in Part 8 with framing {Ta sửa ở Phần 8 bằng framing}.

PropertyTCPUDP (Part 3)
ConnectionYes — handshake firstNo — fire-and-forget
DeliveryGuaranteed (retransmit)Best-effort
OrderPreservedNot guaranteed
AbstractionOrdered byte streamIndependent datagrams
Typical useHTTP, SSH, databasesDNS, gaming, video

The three-way handshake {Bắt tay ba bước}

Before any application data moves, client and server perform a three-way handshake {Trước khi dữ liệu ứng dụng chảy, client và server thực hiện bắt tay ba bước}:

  1. Client → Server: SYN — “I want to connect; my initial sequence number is X” {Client → Server: SYN — “Tôi muốn kết nối; số thứ tự ban đầu của tôi là X”}.
  2. Server → Client: SYN + ACK — “OK, my sequence number is Y; I acknowledge X+1” {Server → Client: SYN + ACK — “OK, số thứ tự của tôi là Y; tôi xác nhận X+1”}.
  3. Client → Server: ACK — “I acknowledge Y+1; connection established” {Client → Server: ACK — “Tôi xác nhận Y+1; kết nối thiết lập”}.

You never write this by hand — the OS does it when you call connect() or accept() {Bạn không viết tay bước này — OS làm khi bạn gọi connect() hoặc accept()}. But knowing it explains connection delays and half-open states {Nhưng biết nó giải thích độ trễ kết nối và trạng thái half-open}.

Client Server SYN SYN + ACK ACK connection established → reliable, ordered byte stream
SYN → SYN+ACK → ACK: only then does the reliable byte stream begin

After the handshake, both sides can write() and read() freely until someone closes the connection {Sau bắt tay, cả hai bên có thể write()read() tự do cho đến khi ai đó đóng kết nối}.


Building a TCP echo server {Xây server TCP echo}

An echo server reads whatever you send and sends it back — the “Hello, World” of sockets {Server echo đọc bất cứ gì bạn gửi và gửi lại — “Hello, World” của socket}. Node’s node:net module exposes everything we need {Module node:net của Node cung cấp mọi thứ ta cần}.

import { createServer, type Socket } from 'node:net';

const PORT = 3000;

function handleConnection(socket: Socket): void {
  console.log(
    `connected: ${socket.remoteAddress}:${socket.remotePort}`,
  );

  socket.on('data', (chunk: Buffer) => {
    const text = chunk.toString('utf8');
    console.log(`received (${chunk.length} bytes):`, JSON.stringify(text));
    socket.write(text); // echo back
  });

  socket.on('end', () => {
    console.log('client finished sending (FIN received)');
  });

  socket.on('error', (err: Error) => {
    console.error('socket error:', err.message);
  });

  socket.on('close', (hadError: boolean) => {
    console.log(`connection closed (hadError=${hadError})`);
  });
}

const server = createServer(handleConnection);

server.on('error', (err: Error) => {
  console.error('server error:', err.message);
});

server.listen(PORT, () => {
  console.log(`echo server listening on port ${PORT}`);
});

Save as echo-server.ts and run {Lưu thành echo-server.ts và chạy}:

npx tsx echo-server.ts    # or: node --import tsx echo-server.ts

Test in another terminal {Thử ở terminal khác}:

nc localhost 3000
hello tcp
hello tcp          # echoed back
# Ctrl+D to close (sends FIN)

Key events on the server socket {Sự kiện chính trên socket server}:

EventWhen it firesWhat to do
'connection'New client connects (on server)Hand off to handler
'data'Bytes arrive from peerProcess chunk; may fire multiple times per “message”
'end'Peer sent FIN (done writing)Stop expecting more input
'error'Something broke (e.g. ECONNRESET)Log; don’t let it go uncaught
'close'Socket fully shut downCleanup resources

socket.remoteAddress and socket.remotePort tell you who connected — invaluable for logging and access control {socket.remoteAddresssocket.remotePort cho biết ai kết nối — vô giá cho logging và kiểm soát truy cập}.


Building a TCP client {Xây client TCP}

The client initiates the connection with createConnection() {Client khởi tạo kết nối bằng createConnection()}:

import { createConnection } from 'node:net';

const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
  console.log('connected to server');
  client.write('hello from the client\n');
});

client.on('data', (chunk: Buffer) => {
  console.log('server replied:', chunk.toString('utf8'));
  client.end(); // send FIN — graceful close
});

client.on('close', () => {
  console.log('connection closed');
});

client.on('error', (err: Error) => {
  console.error('client error:', err.message);
});

Run the echo server first, then {Chạy server echo trước, rồi}:

npx tsx echo-client.ts

Expected output {Output mong đợi}:

connected to server
server replied: hello from the client
connection closed

The callback passed to createConnection fires on 'connect' — the handshake succeeded {Callback truyền vào createConnection chạy khi 'connect' — bắt tay thành công}. You can also use .on('connect', ...) if you prefer the event style {Bạn cũng có thể dùng .on('connect', ...) nếu thích kiểu event}.


The socket lifecycle {Vòng đời socket}

A TCP socket moves through a predictable lifecycle {Socket TCP đi qua vòng đời có thể dự đoán}:

[created] → connect/handshake → [established] → read/write data → [closing] → [closed]

On the server side, createServer listens; each incoming connection spawns a new Socket instance {Ở server, createServer lắng nghe; mỗi kết nối đến tạo một instance Socket mới}. One server process can handle thousands of concurrent sockets {Một process server có thể xử lý hàng nghìn socket đồng thời}.

'data' delivers Buffer chunks, not strings {'data' giao Buffer chunk, không phải string}. Always call .toString('utf8') (or another encoding) when you expect text {Luôn gọi .toString('utf8') (hoặc encoding khác) khi mong đợi text}. Binary protocols keep the Buffer as-is {Giao thức nhị phân giữ nguyên Buffer}.

Accumulate partial data when you need complete messages {Tích lũy dữ liệu một phần khi cần message hoàn chỉnh}:

let buffer = '';

socket.on('data', (chunk: Buffer) => {
  buffer += chunk.toString('utf8');

  // Example: process line-delimited messages
  let newlineIndex: number;
  while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
    const line = buffer.slice(0, newlineIndex);
    buffer = buffer.slice(newlineIndex + 1);
    console.log('complete line:', line);
  }
});

This is a primitive form of framing — Part 8 goes deeper {Đây là dạng framing sơ khai — Phần 8 đi sâu hơn}.


Graceful close vs destroy {Đóng nhẹ nhàng vs destroy}

Two ways to end a connection — they are not the same {Hai cách kết thúc kết nối — chúng không giống nhau}:

MethodWhat happensWhen to use
socket.end()Sends FIN; drains write buffer; half-close (can still read)Normal shutdown after finishing
socket.destroy()Sends RST; drops everything immediatelyTimeout, bad state, force-kill

Graceful close {Đóng nhẹ nhàng}: call socket.end() when done writing {gọi socket.end() khi viết xong}. The peer receives 'end' when your FIN arrives {Peer nhận 'end' khi FIN của bạn đến}. Both sides call end(), both get 'close' — clean shutdown {Cả hai gọi end(), cả hai nhận 'close' — tắt sạch}.

socket.write('goodbye\n');
socket.end(); // FIN sent — "I'm done writing"

Abrupt close {Đóng đột ngột}: socket.destroy() tears down immediately; the peer may see ECONNRESET {socket.destroy() phá ngay lập tức; peer có thể thấy ECONNRESET}. Use for error recovery, not normal flow {Dùng khi phục hồi lỗi, không phải luồng bình thường}.


setNoDelay and setKeepAlive {setNoDelay và setKeepAlive}

Two socket tuning knobs worth knowing {Hai nút tinh chỉnh socket đáng biết}:

socket.setNoDelay(true) — disables Nagle’s algorithm, which batches small writes to reduce packet count {socket.setNoDelay(true) — tắt thuật toán Nagle, gom các lần ghi nhỏ để giảm số packet}. For interactive protocols (games, chat) where latency matters, disable it {Với giao thức tương tác (game, chat) cần độ trễ thấp, hãy tắt}. For bulk transfer, leave the default (Nagle on) {Với truyền bulk, giữ mặc định (Nagle bật)}.

socket.setNoDelay(true);

socket.setKeepAlive(true) — sends periodic probe packets on idle connections so firewalls/NATs don’t drop them {socket.setKeepAlive(true) — gửi packet probe định kỳ trên kết nối idle để firewall/NAT không ngắt}. Useful for long-lived connections (database pools, WebSocket precursors) {Hữu ích cho kết nối sống lâu (pool database, tiền thân WebSocket)}. Optional initial delay in milliseconds {Độ trễ ban đầu tùy chọn tính bằng millisecond}:

socket.setKeepAlive(true, 30_000); // probe after 30s idle

Neither replaces application-level heartbeats for detecting dead peers {Không thay thế heartbeat tầng ứng dụng để phát hiện peer chết}.


Putting it together: run and test {Gắn lại: chạy và thử}

Terminal 1 — start the echo server {Terminal 1 — khởi động server echo}:

npx tsx echo-server.ts

Terminal 2 — raw client with netcat {Terminal 2 — client thô với netcat}:

nc localhost 3000
ping
ping
pong would be wrong you get "ping" echoed

Terminal 3 — run the TypeScript client {Terminal 3 — chạy client TypeScript}:

npx tsx echo-client.ts

Watch the server log: you’ll see remoteAddress, byte counts, 'end', and 'close' for each session {Xem log server: bạn sẽ thấy remoteAddress, số byte, 'end', và 'close' cho mỗi phiên}.


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

  • ❌ Treating each 'data' event as one complete message — TCP is a byte stream; one write can split across many 'data' events, or many writes can merge into one (Part 8) {Coi mỗi 'data'một message hoàn chỉnh — TCP là luồng byte; một lần write có thể tách qua nhiều 'data', hoặc nhiều write gộp thành một (Phần 8)}.
  • ❌ Not handling 'error' on sockets — an uncaught ECONNRESET crashes your Node process {Không xử lý 'error' trên socket — ECONNRESET không bắt sẽ crash process Node}.
  • ❌ Forgetting backpressure exists — socket.write() can return false when the kernel buffer is full; you must pause and wait for 'drain' (Part 8) {Quên backpressure tồn tại — socket.write() có thể trả false khi buffer kernel đầy; phải tạm dừng và chờ 'drain' (Phần 8)}.
  • ❌ Calling destroy() on every disconnect instead of end() — peers see resets and can’t flush pending data {Gọi destroy() mỗi lần ngắt thay vì end() — peer thấy reset và không flush dữ liệu đang chờ}.
  • ❌ Assuming remoteAddress is always an IP string — it can be undefined before connect, or '::ffff:127.0.0.1' for IPv4-mapped IPv6 {Tưởng remoteAddress luôn là chuỗi IP — có thể undefined trước connect, hoặc '::ffff:127.0.0.1' cho IPv4-mapped IPv6}.

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 echo server to prefix every reply with [echo] (e.g. [echo] hello) {Mở rộng server echo để thêm tiền tố [echo] trước mỗi reply}.
  2. Write a client that sends three lines (line1\n, line2\n, line3\n) without waiting for a reply between writes; log how many 'data' events the server fires {Viết client gửi ba dòng mà không chờ reply giữa các lần write; log số sự kiện 'data' server bắn ra}.
  3. Connect with nc, type a message, then kill nc with Ctrl+C (RST) instead of Ctrl+D (FIN). Observe the server’s 'error' / 'close' output {Kết nối bằng nc, gõ message, rồi kill nc bằng Ctrl+C (RST) thay vì Ctrl+D (FIN). Quan sát output 'error' / 'close' của server}.
Solution {Lời giải}
// 1 — prefixed echo (change the write line in handleConnection)
socket.write(`[echo] ${text}`);

// 2 — fire three lines, count server data events
import { createConnection } from 'node:net';

const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
  client.write('line1\n');
  client.write('line2\n');
  client.write('line3\n');
  client.end();
});

client.on('data', (chunk) => {
  console.log('got chunk:', JSON.stringify(chunk.toString()));
});

// On the server, add a counter in 'data':
let dataEvents = 0;
socket.on('data', (chunk) => {
  dataEvents += 1;
  console.log(`data event #${dataEvents}, ${chunk.length} bytes`);
  socket.write(`[echo] ${chunk.toString('utf8')}`);
});
// Typical result: 1 data event with all three lines merged, or 2–3 if timing differs

Exercise 3: Ctrl+C sends RST; the server logs something like socket error: read ECONNRESET and 'close' with hadError=true {Bài 3: Ctrl+C gửi RST; server log kiểu socket error: read ECONNRESET'close' với hadError=true}. Ctrl+D sends FIN gracefully — you get 'end' then 'close' with hadError=false {Ctrl+D gửi FIN nhẹ nhàng — bạn nhận 'end' rồi 'close' với hadError=false}.


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

TCP gives you a reliable, ordered byte stream over a connection established by a three-way handshake {TCP cho bạn luồng byte tin cậy, có thứ tự trên kết nối thiết lập bằng bắt tay ba bước}. In Node.js, createServer and createConnection from node:net wrap that stream with 'data', 'end', 'error', and 'close' events {Trong Node.js, createServercreateConnection từ node:net bọc luồng đó bằng sự kiện 'data', 'end', 'error', và 'close'}. Treat sockets as streams of bytes, handle errors, close gracefully with end(), and remember that framing and backpressure are your job — topics we tackle in Part 8 {Coi socket là luồng byte, xử lý lỗi, đóng nhẹ bằng end(), và nhớ framing cùng backpressure là việc của bạn — ta sẽ làm ở Phần 8}. Next up: UDP — the fast, connectionless alternative in Part 3 {Tiếp theo: UDP — lựa chọn nhanh, không kết nối ở Phần 3}.