Mengeksekusi kode JavaScript arbitrer dari pengguna (multi-tenant) di worker queue membawa risiko operasional tinggi. Modul bawaan seperti vm di Node.js tidak dirancang untuk isolasi keamanan dari kode berbahaya karena konteksnya masih berbagi thread dan heap V8 yang sama. Menjalankan container Docker atau proses baru per job membebani sistem dengan latensi cold-start tinggi dan overhead memori OS.

WebAssembly (WASM) menyediakan sandbox berbasis isolasi memori linier dan komputasi deterministik. Dengan mengompilasi runtime JavaScript ringan (seperti QuickJS) ke WASM dan menjalankannya di host runtime (misalnya Wasmtime atau engine WASM dengan pembatas terintegrasi), resource CPU dan memori tiap job dapat dikontrol secara presisi di level instruksi.

Ancaman Operasional Job JS Tak Tepercaya

Tanpa isolasi ketat, satu job berbahaya dapat melumpuhkan seluruh node worker melalui tiga vektor utama:

  • OOM (Out-of-Memory) DoS: Skrip mengalokasikan array besar atau string berulang hingga melampaui batas heap host, memicu OOM Killer sistem operasi yang langsung menghentikan proses worker utama.
  • Infinite Loop & CPU Starvation: Operasi sinkron tanpa henti (misalnya while(true) atau Regex catastrophic backtracking) memblokir event loop host worker, menghentikan penerimaan heartbeat queue dan memicu timeout massal.
  • Kontaminasi State: Modifikasi pada global object atau prototype pollution yang tertinggal dalam memori runtime dapat bocor ke job penyewa lain yang dieksekusi berikutnya.

Mekanisme Pembatasan: Memory Ceiling dan Fuel Metering

WASM menyelesaikan masalah batas komputasi di tingkat bytecode melalui dua kontrol fundamental:

1. Memory Ceiling (Batas Memori Linier)

Memori WASM bersifat kontinu dan terisolasi dari heap host (disebut linear memory). Saat runtime WASM diinisialisasi, parameter ukuran halaman memori maksimum (di mana 1 page = 64 KB) dapat dikunci. Jika skrip JS di dalam sandbox mencoba mengalokasikan memori melebihi batas tersebut, instruksi memory.grow internal akan gagal dan memicu eksepsi alokasi di level runtime internal, tanpa menaikkan penggunaan RSS proses host secara liar.

2. Fuel / Instruction Metering

Alih-alih mengandalkan setTimeout host yang tidak dapat menginterupsi eksekusi sinkron di thread yang sama, fuel metering menyuntikkan instruksi pengurangan counter pada setiap blok eksekusi WASM. Setiap instruksi mengonsumsi sejumlah unit "fuel". Saat kuota fuel mencapai angka nol, engine WASM langsung memicu trap (interupsi instan). Ini menjamin loop tak terbatas terhenti secara deterministik setelah kuota CPU yang dialokasikan habis.

Implementasi: Sandbox Worker Consumer

Contoh berikut mendemonstrasikan consumer worker menggunakan pola isolasi WASM dengan pembatasan fuel dan alokasi memori per job. Pola ini menangani kesalahan secara terisolasi sehingga kegagalan skrip tidak pernah mematikan proses consumer utama.

import { Worker } from 'bullmq';
import { readFile } from 'node:fs/promises';
// Menggunakan interface runtime WASM hipotetis berfitur metering (misal: @wasmer/sdk atau wasmtime-node)
import { Engine, Store, Module, Instance, Limits } from 'wasm-runtime-provider';

// 1. Muat bytecode QuickJS-WASM sekali saja ke memori host
const wasmBytes = await readFile('./sandboxes/quickjs-sandbox.wasm');
const engine = new Engine();
const compiledModule = new Module(engine, wasmBytes);

// 2. Worker Queue Consumer
const worker = new Worker('untrusted-jobs', async (job) => {
  const { userScript, inputData } = job.data;

  // Alokasi resource ceiling spesifik per job
  // Fuel: 50.000.000 instruksi (~50-100ms CPU bound)
  // Memory: max 256 halaman WASM (256 * 64KB = 16MB)
  const store = new Store(engine);
  store.setFuel(50_000_000n);

  const memoryLimits = new Limits({
    min: 16,     // 1MB initial
    max: 256     // 16MB ceiling mutlak
  });

  let instance;
  try {
    // Inisialisasi sandbox bersih
    instance = await Instance.instantiate(store, compiledModule, {
      env: {
        memory: memoryLimits,
        log_stdout: (ptr, len) => { /* Bridge log terisolasi */ }
      }
    });

    // Kirim payload input dan eksekusi fungsi
    const runScript = instance.exports.runUserPayload;
    const resultJson = runScript(userScript, JSON.stringify(inputData));

    // Validasi sisa fuel untuk metrik observabilitas
    const remainingFuel = store.getFuel();
    job.updateProgress({ fuelConsumed: 50_000_000n - remainingFuel });

    return JSON.parse(resultJson);

  } catch (err) {
    // Eviction handler: Tangkap trap engine tanpa mematikan host
    if (err.message.includes('fuel exhausted')) {
      throw new Error(`Execution aborted: CPU budget exceeded (Infinite loop detected).`);
    }

    if (err.message.includes('out of memory') || err.message.includes('unreachable')) {
      throw new Error(`Execution aborted: Memory limit (16MB) exceeded.`);
    }

    // Tangkap sintaks/runtime error dari script pengguna
    throw new Error(`Execution failed: ${err.message}`);

  } finally {
    // Drop context/store secara eksplisit untuk mencegah leak
    store.dispose();
    instance = null;
  }
}, {
  concurrency: 8,
  connection: { host: 'localhost', port: 6379 }
});

Strategi Pooling Instance untuk Mengurangi Cold-Start

Kompilasi ulang biner WASM pada setiap job akan memicu overhead CPU signifikan (10ms hingga 100ms). Strategi optimasi performa berpusat pada pemisahan antara artefak kompilasi dan state runtime:

  1. Module Sharing (Zero-copy): Kompilasi bytecode WASM dilakukan hanya satu kali pada saat worker booting menggunakan WebAssembly.compile(). Objek WebAssembly.Module bersifat stateless dan aman dipakai bersama oleh banyak thread/job.
  2. Lightweight Store Recreation: Daripada pooling instance yang berisiko mempertahankan mutasi heap JavaScript sebelumnya, buat objek Store / Context baru untuk setiap job dari module yang sudah dikompilasi. Menginstansiasi instance dari pre-compiled module hanya memerlukan waktu sub-milidetik (0.1ms - 0.5ms).
  3. Warm Context Pool (Optional): Jika beban throughput sangat masif, pertahankan pool berisi instance yang sudah diinisialisasi. Ketika job selesai atau mengalami trap akibat pelanggaran batas, buang (destroy) instance tersebut sepenuhnya dari pool dan gantikan dengan instance baru. Jangan menggunakan kembali instance yang terkena trap alokasi memori atau timeout CPU.

Trade-offs dan Batasan

Isolasi sandbox berbasis WASM bukan solusi tanpa kompromi:

  • Ketiadaan Fitur Node.js API: Sandbox WASM tidak memiliki akses ke sistem file host, native socket, atau modul npm yang bergantung pada C-bindings, kecuali host secara eksplisit menyediakan fungsinya lewat WASI/bridge interfaces.
  • Penalti Performa Eksekusi: Menjalankan interpreter JS (seperti QuickJS) di dalam WASM menghasilkan performa komputasi 2x hingga 5x lebih lambat dibanding eksekusi V8 native dengan JIT (Just-In-Time). Pendekatan ini ideal untuk skrip transformasi data, verifikasi payload, dan webhook logika bisnis pendek, bukan untuk pengolahan grafik intensif.
  • Debugging Stack Trace: Stack trace error yang dihasilkan dari dalam interpreter WASM memerlukan layer translasi tambahan agar dapat memetakan baris error asli skrip pengguna secara akurat.