Setelah memahami peta konsepnya, saatnya kita menulis server TCP pertama dengan module node:net . Semua aplikasi berbasis koneksi — dari HTTP server sampai basis data — pada dasarnya adalah program yang mendengarkan port, menerima socket dari client, lalu bertukar byte lewat socket tersebut. Pada chapter ini kita akan membangun echo server yang menggema balik setiap data yang diterima, lalu meningkatkannya menjadi server yang mengubah teks menjadi huruf besar sambil melayani beberapa koneksi secara berurutan. Sepanjang jalan kita akan mengenal net.createServer() , event 'connection' dan 'data' , cara menutup server dengan rapi, serta kebiasaan aman menangani error pada socket.

Server Pertama: Echo Server #

Satu server TCP minimal di Node.js hanya butuh tiga bahan: object net.Server dari net.createServer(), panggilan listen() untuk mulai mendengarkan, dan satu handler untuk event 'connection' yang menerima socket client. Simpan kode berikut sebagai echo-server.mjs:

js
import net from 'node:net';

const server = net.createServer();

server.on('connection', (socket) => {
  console.log('koneksi masuk dari', socket.remoteAddress);

  socket.on('data', (chunk) => {
    console.log('server menerima:', JSON.stringify(chunk.toString('utf8')));
    socket.write(chunk);
  });

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

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

server.listen(0, '127.0.0.1', () => {
  const port = server.address().port;

  const client = net.connect({ port, host: '127.0.0.1' });
  client.setEncoding('utf8');

  client.on('connect', () => {
    console.log('client terhubung, mengirim pesan');
    client.write('halo dari client');
  });

  client.on('data', (data) => {
    console.log('client menerima gema:', JSON.stringify(data));
    client.end();
  });

  client.on('error', () => {});

  client.on('close', () => {
    server.close(() => {
      console.log('server berhenti mendengarkan');
      process.exit(0);
    });
  });
});

Output yang Dihasilkan:

text
koneksi masuk dari 127.0.0.1
client terhubung, mengirim pesan
server menerima: "halo dari client"
client menerima gema: "halo dari client"
server berhenti mendengarkan

Agar contoh bisa dijalankan sebagai satu berkas dan berhenti dengan rapi, kode di atas memuat server beserta klien uji kecil di berkas yang sama — pola yang akan kita pakai berulang kali di Section ini. Mari kita bedah baris-per-baris bagian yang menjadi fokus kita, yaitu sisi server:

  1. Baris 3 (net.createServer()): membuat object net.Server baru. Tanpa argumen, tidak ada handler koneksi yang terpasang; kita memasangnya lewat event di baris berikutnya. Bentuk singkat net.createServer(handler) juga sah — handler tersebut otomatis dipasang ke event 'connection'.
  2. Baris 5 (server.on('connection', ...)): setiap kali client berhasil menyelesaikan jabat tangan TCP, server menyerahkan satu object net.Socket yang mewakili koneksi tersebut. Di sinilah perjalanan data dimulai.
  3. Baris 8 (socket.on('data', ...)): event 'data' terbit setiap kali ada potongan byte baru tiba. Parameter chunk berupa Buffer, sehingga kita memakai chunk.toString('utf8') agar bisa dibaca manusia, lalu JSON.stringify() agar batas persis pesan terlihat jelas di output.
  4. Baris 10 (socket.write(chunk)): mengirim data kembali ke pengirim — inti dari echo server. Method ini mengembalikan nilai boolean dan bisa menerima callback, sama seperti stream Writable pada Section Buffers dan Streams.
  5. Baris 13-15 (socket.on('error', ...)): socket adalah perangkat luar yang bisa gagal kapan saja. Tanpa handler 'error', satu kegagalan akan menjadi exception yang membuat seluruh proses berhenti.
  6. Baris 22 (server.listen(0, '127.0.0.1', ...)): mulai mendengarkan di alamat loopback. Port 0 adalah permintaan khusus: “pilihkan port yang masih kosong”. Nomor aslinya bisa kita baca dari server.address().port di dalam callback — angka tersebut berbeda pada setiap eksekusi, itulah sebabnya contoh ini tidak mencetaknya.

Event data Datang sebagai Potongan Buffer #

Sisi client pada contoh di atas sengaja ditempatkan setelah listen() dan di dalam callback-nya, karena port ephemeral baru diketahui setelah server benar-benar mendengarkan. Client memanggil net.connect(), menunggu event 'connect', lalu mengirim satu pesan. Ketika gema tiba, client memanggil client.end() untuk menutup sisi kirimnya, dan pada event 'close' kita menutup server dengan server.close() supaya proses keluar dengan bersih.

Ada satu kebiasaan TCP yang harus kita tanamkan sejak dini: TCP adalah aliran byte, bukan antrean pesan. Satu panggilan write() di client tidak menjamin satu event 'data' di server — data bisa tiba terpotong menjadi beberapa event, atau beberapa write bisa menyatu dalam satu event. Karena demo kita mengirim satu pesan kecil lalu menunggu gema, potongannya stabil; pada chapter Membangun Protokol Berbasis Baris kita akan menangani pemotongan ini secara eksplisit.

Berikutnya kita naik kelas: server yang menerima teks, mengubahnya menjadi huruf besar, dan mengembalikannya, sekaligus membuktikan bahwa satu server bisa melayani banyak koneksi berurutan. Kode uji client-nya juga memperlihatkan properti server.address() yang lengkap:

js
import net from 'node:net';

const server = net.createServer();

let jumlahKoneksi = 0;

server.on('connection', (socket) => {
  jumlahKoneksi += 1;
  const nomor = jumlahKoneksi;

  socket.on('data', (chunk) => {
    console.log(`koneksi ${nomor} menerima ${chunk.length} byte:`,
      JSON.stringify(chunk.toString('utf8')));
    socket.write(chunk.toString('utf8').toUpperCase());
  });

  socket.on('error', (err) => {
    console.log(`koneksi ${nomor} error:`, err.code);
  });
});

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

const tungguBalasan = (client) =>
  new Promise((resolve) => {
    const tangkap = (chunk) => {
      console.log('balasan:', chunk.toString('utf8'));
      client.off('data', tangkap);
      resolve();
    };
    client.on('data', tangkap);
  });

server.listen(0, '127.0.0.1', async () => {
  const port = server.address().port;
  const info = server.address();
  console.log('server mendengarkan di', info.address, `(${info.family})`);

  for (let i = 1; i <= 2; i++) {
    await new Promise((resolve) => {
      const client = net.connect({ port, host: '127.0.0.1' }, async () => {
        console.log(`--- koneksi ${i} ---`);
        client.write('halo ');
        await tungguBalasan(client);
        client.write('dunia tcp');
        await tungguBalasan(client);
        client.end();
      });
      client.on('error', () => {});
      client.on('close', () => resolve());
    });
  }

  server.close(() => {
    console.log('total koneksi yang dilayani:', jumlahKoneksi);
    process.exit(0);
  });
});

Output yang Dihasilkan:

text
server mendengarkan di 127.0.0.1 (IPv4)
--- koneksi 1 ---
koneksi 1 menerima 5 byte: "halo "
balasan: HALO
koneksi 1 menerima 9 byte: "dunia tcp"
balasan: DUNIA TCP
--- koneksi 2 ---
koneksi 2 menerima 5 byte: "halo "
balasan: HALO
koneksi 2 menerima 9 byte: "dunia tcp"
balasan: DUNIA TCP
total koneksi yang dilayani: 2

Beberapa hal baru terjadi di sini. Counter jumlahKoneksi bertambah setiap event 'connection' — server tetap hidup dan menyimpan datanya sendiri di antara koneksi, inilah yang dimaksud server stateful skala kecil. Setiap socket mendapat salinan nomor sehingga log-nya tidak tertukar. Helper tungguBalasan() menjanjikan satu balasan per kiriman, sehingga client uji bisa mengirim pesan kedua hanya setelah balasan pertama diterima; tanpa penjadwalan seperti ini, dua write() beruntun bisa menyatu dan mengubah cara data tiba di server. Terakhir, server.address() mengembalikan object berisi address, family, dan port — kita mencetak address dan family saja karena port bersifat acak.

Pada kedua contoh, proses berakhir lewat rangkaian penutupan yang urutannya penting. Client memanggil end() untuk menutup sisi kirimnya; server socket mengikuti karena secara default Node.js menutup balik arah kirimnya begitu menerima penutupan dari lawan. Setelah semua socket client selesai, server.close() menghentikan socket pendengar, dan callback-nya menjadi tempat yang tepat untuk pekerjaan terakhir seperti mencatat statistik sebelum process.exit(0).

Pemanggilan server.close() tidak memutus client yang masih terhubung — method ini menunggu sampai semua koneksi berakhir, baru event 'close' milik server terbit. Jika kita ingin berhenti segera tanpa menunggu, setiap socket bisa diputus paksa dengan socket.destroy(). Perbedaan lembut tutup (end()) versus putus paksa (destroy()) ini akan kita lihat lagi dari sisi client pada chapter berikutnya.

Menangani Error Sejak Awal #

Dua handler 'error' pada contoh bukan hiasan. Error pada socket — koneksi terputus di tengah jalan, lawan tiba-tiba pergi — adalah kejadian normal di dunia jaringan, bukan pengecualian langka. Node.js menerapkan aturan keras dari EventEmitter: event 'error' yang tidak memiliki listener akan dilempar sebagai exception dan menghentikan proses. Satu client nakal yang memutus koneksi secara tidak sopan cukup untuk meruntuhkan server yang tidak siap.

Error pada level server juga ada, yang paling umum adalah EADDRINUSE ketika port yang diminta sudah dipakai program lain. Karena contoh kita memakai port 0, kejadian tersebut tidak mungkin terjadi di sini — salah satu alasan pola ini nyaman untuk latihan. Dalam produksi, port biasanya tetap (misalnya 3000), jadi handler 'error' pada server adalah tempat terbaik memutuskan strategi: mencoba port lain, atau benar-benar berhenti dan melaporkan.

Kita kini punya server yang bisa menerima koneksi, memproses Buffer, melayani banyak client, dan ditutup dengan bersih. Ceritanya belum lengkap: semua klien uji kita selalu berhasil terhubung. Bagaimana rasanya menulis sisi client yang sesungguhnya — menunggu event 'connect', membaca data, menghadapi kegagalan, bahkan mencoba menyambung ulang — menjadi fokus Chapter Membangun TCP Client.