Kita kini menguasai semua bahan dasarnya: server TCP, client TCP, UDP, dan DNS. Chapter penutup Section ini menggabungkan semuanya menjadi satu keterampilan yang membedakan pengguna module dari perancang sistem: merancang protokol sendiri. Setiap aplikasi jaringan yang saling bicara harus sepakat tentang format pesan — HTTP, SMTP, dan Redis semua memulai dari ide sederhana yang sama: perintah berupa baris teks yang diakhiri newline. Kita akan merancang protokol kecil bernama percakapan PING, TIME, ECHO, dan BYE, lengkap dengan parser baris yang tahan terhadap pemotongan paket — jebakan terbesar TCP yang sudah kita tandai sejak chapter pertama Section ini.
Mengapa Baris Teks #
Protokol berbasis baris memakai aturan pembingkaian paling sederhana yang bisa dibaca manusia: satu pesan adalah satu baris, dan baris berakhir saat karakter \n ditemukan. Aturan ini murah diimplementasikan, mudah dites dengan telnet atau nc, dan mudah dideteksi kesalahannya. SMTP, FTP, IRC, hingga baris permintaan dan header HTTP semuanya berbasis baris; Redis dengan protokol RESP-nya pun tetap berbasis baris di level perintah.
Kelemahannya juga jelas: teks lebih boros byte daripada biner, dan mencari pemisah \n menuntut proses per karakter. Untuk protokol internal, alat uji, dan layanan berintensitas perintah, trade-off ini hampir selalu menguntungkan. Tabel berikut merangkumannya:
| Aspek | Protokol baris teks | Protokol biner |
|---|---|---|
| Keterbacaan saat debugging | sangat mudah | perlu hex dump |
| Ukuran pesan | lebih besar | lebih ringkas |
| Pembingkaian | cari karakter \n | panjang tetap di awal pesan |
| Contoh | HTTP, SMTP, Redis | protobuf, WebSocket frame |
Rancangan Protokol #
Protokol kita bernama sederhana: client mengirim satu baris perintah, server membalas satu baris untuk setiap perintah yang diterima. Empat perintah yang didukung:
| Perintah dari client | Balasan server | Perilaku |
|---|---|---|
PING | PONG <n> | penghitung ping bertambah setiap koneksi |
TIME | stempel waktu beku | waktu sengaja dibekukan agar hasil contoh selalu sama |
ECHO <pesan> | isi pesan digandakan | latihan ekstraksi argumen |
BYE | SAMPAI JUMPA | server menutup koneksi setelah membalas |
Perintah lain dijawab ERROR perintah tidak dikenal tanpa memutus koneksi — keputusan desain yang penting: server yang memutus koneksi pada kesalahan kecil mudah diserang dengan membanjiri koneksi baru. Yang paling menantang bukan daftar perintahnya, melainkan cara membacanya: TCP menjamin urutan byte tetapi tidak menjamin bahwa satu write() tiba sebagai satu event 'data'. Server kita wajib menampung sisa byte yang belum membentuk baris utuh.
Parser Baris yang Tahan Pemotongan #
Inti seluruh chapter ini adalah satu function kecil. Parser menyimpan byte yang belum selesai dalam variable sisa, menambah setiap potongan yang datang lewat event 'data'
, lalu mengeluarkan semua baris lengkap yang bisa ditemukan:
const buatParserBaris = (saatDapatBaris) => {
let sisa = '';
return (chunk) => {
sisa += chunk.toString('utf8');
let posisi;
while ((posisi = sisa.indexOf('\n')) !== -1) {
const baris = sisa.slice(0, posisi).replace(/\r$/, '');
sisa = sisa.slice(posisi + 1);
saatDapatBaris(baris);
}
return sisa.length;
};
};Empat keputusan kecil di dalamnya masing-masing punya alasan. Loop while (bukan sekali if) mengantisipasi satu event 'data' yang memuat beberapa baris sekaligus — dua write() beruntun dari client bisa menyatu begitu. Method replace(/\r$/, '') membuang carriage return dari client Windows atau telnet yang mengirim \r\n. Potongan yang tersisa disimpan ke sisa untuk dilengkapi potongan berikutnya, dan nilai baliknya panjang sisa — dipakai pemanggilnya sebagai alat pengaman, seperti akan kita lihat di demo. Parser ini sendiri adalah closure: setiap socket harus mendapat parser sendiri agar sisa antar-koneksi tidak tercampur.
Demo Lengkap: Percakapan Terencana #
Berkas protokol-baris.mjs memuat server lengkap beserta client terencana yang menuntaskan seluruh percakapan — termasuk satu baris yang sengaja dikirim dalam dua potongan terpisah untuk membuktikan parser bekerja:
import net from 'node:net';
// Parser baris: menampung potongan byte sampai sebuah baris utuh terbentuk.
const buatParserBaris = (saatDapatBaris) => {
let sisa = '';
return (chunk) => {
sisa += chunk.toString('utf8');
let posisi;
while ((posisi = sisa.indexOf('\n')) !== -1) {
const baris = sisa.slice(0, posisi).replace(/\r$/, '');
sisa = sisa.slice(posisi + 1);
saatDapatBaris(baris);
}
return sisa.length;
};
};
const WAKTU_BEKU = new Date(Date.UTC(2026, 0, 15, 1, 0, 0)).toISOString();
const server = net.createServer((socket) => {
let jumlahPing = 0;
const prosesBaris = buatParserBaris((baris) => {
if (baris === 'PING') {
jumlahPing += 1;
socket.write(`PONG ${jumlahPing}\n`);
} else if (baris === 'TIME') {
socket.write(`${WAKTU_BEKU}\n`);
} else if (baris.startsWith('ECHO ')) {
socket.write(`${baris.slice(5)}\n`);
} else if (baris === 'BYE') {
socket.end('SAMPAI JUMPA\n');
} else {
socket.write('ERROR perintah tidak dikenal\n');
}
});
socket.on('data', (chunk) => {
const sisa = prosesBaris(chunk);
if (sisa > 1024) {
socket.destroy();
}
});
socket.on('error', () => {});
});
server.on('error', () => {});
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
const client = net.connect({ port, host: '127.0.0.1' });
client.setEncoding('utf8');
let pemantauBalasan = null;
client.on('data', buatParserBaris((baris) => {
console.log(`S > ${baris}`);
if (pemantauBalasan) {
const pulaskan = pemantauBalasan;
pemantauBalasan = null;
pulaskan();
}
}));
client.on('error', () => {});
const tungguBalasan = () =>
new Promise((resolve) => { pemantauBalasan = resolve; });
const kirim = (teks) => {
console.log(`C > ${teks.replace(/\n$/, '')}`);
client.write(teks);
};
client.on('close', () => server.close(() => process.exit(0)));
await new Promise((resolve) => client.once('connect', resolve));
kirim('PING\n');
await tungguBalasan();
kirim('ECHO halo protokol\n');
await tungguBalasan();
kirim('PI');
kirim('NG\n');
await tungguBalasan();
kirim('TIME\n');
await tungguBalasan();
kirim('DUMMY\n');
await tungguBalasan();
kirim('BYE\n');
await tungguBalasan();
client.end();
});Output yang Dihasilkan:
C > PING
S > PONG 1
C > ECHO halo protokol
S > halo protokol
C > PI
C > NG
S > PONG 2
C > TIME
S > 2026-01-15T01:00:00.000Z
C > DUMMY
S > ERROR perintah tidak dikenal
C > BYE
S > SAMPAI JUMPATranskrip tersebut layak dibaca pelan-pelan karena memuat semua pelajaran Section ini. Enam baris C > dan S > berselang-seling: client mengirim, menunggu balasan utuh, baru mengirim lagi — pola tanya-jawab yang membuat urutan output pasti. Mari kita bedah bagian-bagian pentingnya:
- Baris 4-16 (
buatParserBaris): parser yang sudah kita bahas, dipakai dua kali — sekali di server untuk memproses perintah, sekali di client untuk membaca balasan. Kedua pemakaian mendapat closure berbeda sehinggasisamasing-masing terisolasi. - Baris 23-36 (handler perintah): percabangan
if/else ifini adalah keseluruhan protokol. Balasan selalu diakhiri\nagar client bisa memakai parser yang sama. - Baris 38-43 (pengaman panjang): nilai balik
sisa.lengthdari parser diperiksa setelah setiap potongan diproses; jika byte yang tertampung melebihi 1024 tanpa pernah membentuk baris, koneksi diputus paksa dengansocket.destroy()— tanpa pengaman ini, client jahat bisa menumpuk memori server dengan mengirim baris tak berujung. - Baris 21 (
jumlahPing): berada di dalam handler koneksi, sehingga setiap koneksi mendapat penghitung sendiri — inilah yang membuat balasanPONGbernilai2meski barisPINGketiga datang terpotong-potong. - Baris 32 (
socket.end('SAMPAI JUMPA\n')): data terakhir dikirim dan koneksi ditutup sopan dalam satu panggilan — client masih sempat menerima baris terakhir sebelumclose. - Baris 53-61 (client parser): client memakai parser yang sama. Variabel
pemantauBalasanmenjadi jembatan event-ke-promise: setiap baris balasan dicetak, lalu promise yang sedang menunggu dipulihkan. - Baris 82-84 (
kirim('PI')lalukirim('NG\n')): momen pembuktian. Dua potongan dikirim dalam dua panggilanwrite()terpisah — bisa tiba sebagai satu event'data'atau dua, kita tidak bisa mengandalkan salah satunya. Output membuktikan server tetap menjawabPONG 2karena parser menunggu\nsebelum memproses. - Baris 92-95 (
kirim('BYE\n')laluclient.end()): protokol ditutup dari sisi client dengan perintahBYE, balasan terakhir diterima, baru socket diakhiri sopan.
Pelajaran untuk Protokol Berikutnya #
Rancangan kita masih jauh dari produksi, dan justru kekurangannya yang mengajar. Tidak ada otentikasi, tidak ada versi protokol, dan format balasan selalu satu baris — protokol nyata biasanya punya mode multi-baris (seperti header HTTP yang berakhir baris kosong). Nilai batas 1024 byte kita punya, tetapi protokol serius juga membatasi jumlah baris per detik agar server tidak dibanjiri. Semua penghalusan itu menumpuk di atas fondasi yang sama: pembingkaian yang tahan pemotongan, status per koneksi, dan penutupan yang sopan lewat write()
dan end()
.
Kita telah menyelesaikan perjalanan level rendah Node.js: dari konsep lapisan jaringan, TCP server dan client, UDP dengan datagramnya, DNS, sampai merancang protokol sendiri. Ada satu nama yang sengaja belum kita sentuh sejak awal Section ini: HTTP. Protokol berbasis baris yang kita rancang di chapter ini adalah nenek moyang HTTP — dan module node:http tinggal menunggu kita membedahnya pada Section HTTP dan Web Servers.