Salah satu kekuatan besar alat command line adalah kemampuannya merangkai program lain: mengompres berkas, menjalankan migrasi database, atau memanggil git dari dalam script kita sendiri. Node.js membuka kemampuan ini lewat module node:child_process, yang menyediakan empat function untuk menjalankan proses lain — masing-masing dengan karakter yang berbeda. Pada chapter ini kita membandingkan keempatnya, menjalankan program dengan aman, mengalirkan output besar secara streaming, dan berkomunikasi dua arah dengan proses anak sesama Node.js lewat kanal IPC.

Empat Pintu ke Proses Lain #

Pilihan function menentukan cara kita menulis perintah, apakah shell ikut terlibat, dan bagaimana output dikumpulkan:

FunctionBentuk perintahLewat shellOutputCocok untuk
exec()satu string perintahyadibuffer seluruhnyaperintah shell ringan
execFile()nama program + array argumentidakdibuffer seluruhnyaprogram dengan argumen dari pengguna
spawn()nama program + array argumentidakstreamingoutput besar atau proses berjalan lama
fork()path module JavaScript + array argumentidakstreaming + kanal IPCproses kerja Node.js

Perbedaan buffering ini punya konsekuensi nyata: exec() dan execFile() menunggu proses anak selesai lalu menyerahkan seluruh output sebagai satu string — bila output melebihi maxBuffer (bawaannya 1 MiB), proses anak dihentikan dan output-nya terpotong. spawn() dan fork() mengalirkan potongan output begitu tersedia, sehingga tak ada batas ukuran dan layak untuk berkas raksasa maupun proses yang berjalan lama.

execFile: Program dan Argumen yang Terpisah #

execFile() menjalankan satu program dengan argumen yang sudah dipisah menjadi array — tanpa shell perantara. Berkas jalankan-node.mjs menjalankan node itu sendiri sebagai anak:

js
import { execFile } from "node:child_process";

execFile("node", ["-e", "console.log(40 + 2)"], (error, stdout, stderr) => {
  if (error) {
    console.error("gagal:", error.message);
    return;
  }
  console.log("hasil:", stdout.trim());
  console.log("stderr kosong:", stderr === "");
});

Output yang Dihasilkan:

text
hasil: 42
stderr kosong: true

Mari kita telaah baris-per-baris kode di atas:

  1. Baris 3 (execFile("node", [...])): program node dipanggil dengan dua argumen — flag -e dan potongan kode yang mencetak 40 + 2. Argumen berbentuk array, bukan satu string rakitan, sehingga setiap argumen sampai ke program anak apa adanya.
  2. Baris 3 (callback): callback gaya klasik Node.js menerima tiga parameter: error terisi bila proses gagal dijalankan atau keluar dengan kode bukan nol, stdout berisi seluruh output standar, dan stderr berisi output galat.
  3. Baris 4-7 (penanganan error): pola if (error) return menghentikan eksekusi callback sejak awal — bila program tidak jalan, tidak ada hasil yang layak diproses.
  4. Baris 9 (stdout.trim()): output anak diakhiri baris baru, maka trim() membuangnya agar hasil rapi dicetak satu baris.

exec dan Bahaya Perintah Rakitan #

exec() menerima satu string perintah lengkap lalu menyerahkannya ke shell — pipe, redirection, dan variable shell ikut bekerja:

js
import { exec } from "node:child_process";

exec('node -e "console.log(7 * 6)"', (error, stdout) => {
  if (error) {
    console.error("gagal:", error.message);
    return;
  }
  console.log("hasil:", stdout.trim());
});

Output yang Dihasilkan:

text
hasil: 42

Kemudahan ini datang dengan risiko serius begitu masukan pengguna ikut dirangkai ke dalam string perintah:

js rentan-injeksi.mjs
// CONTOH RENTAN - hanya untuk pembelajaran, jangan dijalankan di production
import { exec } from "node:child_process";

const namaBerkas = process.argv[2]; // berasal dari pengguna
exec(`gzip ${namaBerkas}`, () => {});

Bila pengguna mengetik data.txt; rm -rf /, shell membaca titik koma sebagai pemisah perintah dan menjalankan bagian kedua di luar niat kita — inilah serangan command injection. Versi amannya memakai execFile(): argumen dikirim sebagai elemen array yang shell tidak pernah menafsirkan, sehingga execFile("gzip", [namaBerkas]) memperlakukan string aneh itu sebagai nama berkas biasa (yang memang gagal dibuka, tanpa efek samping). Pembahasan mendalam tentang validasi masukan menanti kita di Section Security; untuk sekarang, jadikan execFile() pilihan bawaan dan sisakan exec() hanya untuk perintah yang kita susun sendiri tanpa masukan luar.

spawn: Mengalirkan Output #

Untuk proses beroutput besar, spawn() tidak menunggu sampai selesai — ia mengembalikan object proses anak yang output-nya dikonsumsi sebagai stream:

js
import { spawn } from "node:child_process";

const anak = spawn("node", ["-e", "console.log(10 * 3); process.exit(3)"]);

anak.stdout.on("data", (potongan) => {
  console.log(`output: ${String(potongan).trim()}`);
});

anak.on("close", (kode) => {
  console.log(`proses anak berakhir dengan kode ${kode}`);
});

Output yang Dihasilkan:

text
output: 30
proses anak berakhir dengan kode 3

Property anak.stdout adalah stream baca: setiap potongan output memicu event data begitu tersedia, dan potongan tersebut berupa Buffer — String(potongan) mengubahnya ke teks. Event close terpicu setelah proses anak berakhir dan seluruh stdio-nya selesai dialirkan, dengan parameter kode berisi exit code anak; di atas nilainya 3 karena program kecil itu memang keluar dengan process.exit(3). Stream anak.stderr tersedia dengan pola listener yang sama persis bila kita ingin mengalirkan pesan galat secara terpisah. Berbekal exit code anak, error handling di chapter Exit Code berlaku juga di sini.

fork: Anak Node.js dengan Kanal IPC #

fork() adalah spesialisasi spawn() untuk menjalankan module Node.js: proses anak otomatis mendapat kanal komunikasi bawaan, sehingga induk dan anak bertukar pesan JavaScript tanpa perlu mem-parsing output teks. Berkas anak.mjs berperan sebagai pekerja yang menunggu tugas:

js
process.on("message", (tugas) => {
  const hasil = tugas.a + tugas.b;
  process.send({ nama: tugas.nama, hasil });
  process.disconnect();
});

Dijalankan sendirian, berkas ini langsung berakhir tanpa menghasilkan apa pun — ia memang dirancang menunggu pesan dari induknya. Sedangkan induk.mjs mengirim tugas dan menunggu balasan:

js
import { fork } from "node:child_process";

const anak = fork("anak.mjs");

anak.send({ nama: "penjumlahan", a: 40, b: 2 });

anak.on("message", (pesan) => {
  console.log(`hasil ${pesan.nama}: ${pesan.hasil}`);
});

anak.on("close", (kode) => {
  console.log(`proses anak berakhir dengan kode ${kode}`);
});

Output yang Dihasilkan:

text
hasil penjumlahan: 42
proses anak berakhir dengan kode 0

Alurnya berjalan dua arah. Induk memanggil anak.send(...) untuk mengirim object tugas; di sisi anak, listener process.on("message", ...) menerima object yang sama dalam kondisi utuh — bukan teks yang harus di-parse. Anak menghitung hasil, membalas lewat process.send(...), lalu process.disconnect() menutup kanal dari sisinya. Induk menerima balasan di listener message, dan begitu anak berakhir, event close melaporkan kode keluarnya. Pola inilah fondasi worker untuk membagi beban CPU — topik yang kita dalami di Section Worker Threads.

Dengan node:child_process, program Node.js kita bisa berdiri di tengah ekosistem alat lain. Satu bab lagi menutup section ini: menyatukan semua bahan — argumen, opsi, help, dan subperintah — menjadi aplikasi CLI yang utuh memakai package Commander.

Referensi lanjutan untuk topik chapter ini: