D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Tunnel ngrok Membalas 200 `text/plain`, Bukan JSON? Header `ngrok-skip-browser-warning` Ditolak Preflight CORS

· · 8 menit baca
Tunnel ngrok Membalas 200 `text/plain`, Bukan JSON? Header `ngrok-skip-browser-warning` Ditolak Preflight CORS

Permintaannya terdengar seperti pekerjaan satu baris. Rekan tim yang memegang backend menyerahkan sebuah URL *.ngrok-free.dev dan meminta frontend disambungkan ke sana. Backend-nya Django yang berjalan lokal di balik tunnel ngrok, dan alamat tunnel itu berganti-ganti. Frontend-nya aplikasi React milik klien di atas Create React App (react-scripts 5.0.1), dan klien axios-nya dibuat dengan baseURL yang dibaca dari process.env.REACT_APP_BASE_URL. Kelihatannya cukup mengganti satu variabel lingkungan.

Ternyata tidak cukup, dan cara gagalnya membingungkan. Pada 4 September 2026 urusan ini menghabiskan waktu sungguhan, padahal axios tidak melempar satu galat pun.

Status 200, isinya halaman peringatan

Di paket gratis, ngrok menyajikan halaman peringatan interstitial ke setiap request yang User-Agent-nya terlihat seperti browser. Halaman itu tidak terbatas pada navigasi. XHR yang membawa Sec-Fetch-Mode: cors dan Accept: application/json, yang dari header-nya saja jelas meminta data, tetap mendapatkannya. Penentunya User-Agent, bukan jenis request.

Balasan interstitial itu berstatus HTTP 200 dengan tipe text/plain. Axios di aplikasi ini dideklarasikan dengan rentang ^1.3.4, jadi masih keluarga 1.x, dan secara bawaan axios hanya menolak promise untuk status di luar rentang 2xx. Balasan itu lolos sebagai sukses. Tidak ada galat yang bisa ditangkap, dan aplikasi hanya menerima halaman berisi prosa di tempat yang seharusnya JSON. Pesan commit perbaikannya mencatat persis itu: axios di aplikasi ini menerima halaman peringatan, bukan JSON.

Di situlah bagian yang membingungkan. Status yang biasanya dibaca sebagai tanda beres justru menutupi masalahnya, dan kesalahannya baru terlihat kalau isi balasannya dibaca.

Header skip-nya butuh izin dari backend

Jalan keluar yang terdokumentasi untuk melewati halaman itu adalah header ngrok-skip-browser-warning. Jebakannya, browser tidak bisa mengirim header itu kecuali backend mencantumkannya di konfigurasi CORS.

Sebabnya ada di aturan CORS. Header kustom pada request lintas origin membuat browser mengirim preflight lebih dulu, yaitu request OPTIONS yang menanyakan apakah origin dan header itu boleh dipakai. Request aslinya baru dikirim kalau balasan preflight mengizinkan origin-nya dan menyebut header tersebut di Access-Control-Allow-Headers. Origin yang diizinkan saja tidak cukup.

Justru origin yang sudah diizinkan itulah yang membuat penyiapannya tampak siap. Backend sudah mengizinkan http://localhost:3000 sebagai origin, dan celah CORS-nya hanya terlihat di daftar allow-headers. Pesan commit perbaikan mencatat bahwa ngrok-skip-browser-warning tidak tercantum di CORS_ALLOW_HEADERS backend, sehingga browser juga tidak bisa mengirimnya langsung.

Hal ini bisa dipastikan sebelum berasumsi, dengan satu preflight lewat curl yang memakai origin dev server:

curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Headers: ngrok-skip-browser-warning" \
  https://<tunnel>/v1/ | grep -i access-control-allow-headers

Kalau ngrok-skip-browser-warning tidak ada di daftar balasan itu, sambungan langsung dari browser mustahil dari dua arah. Tanpa header, request kena interstitial. Dengan header, preflight-nya ditolak.

Jalan yang paling lurus adalah meminta rekan backend menambahkan header itu ke CORS_ALLOW_HEADERS. Catatan proyek saya memang mendaftarnya sebagai syarat sebelum aplikasi bisa bicara dengan tunnel tanpa dev proxy, dan butir itu masih tercatat terbuka. Yang saya pilih justru proxy milik dev server sendiri, bukan perubahan di backend.

Lompatan ke tunnel pindah ke Node

Di Create React App, proxy semacam itu ditulis di src/setupProxy.js. Dengan proxy itu, browser bicara ke origin-nya sendiri sehingga CORS hilang sama sekali, dan lompatan ke tunnel terjadi di Node, tempat header bisa dikendalikan. Dev server kini meneruskan /v1 dan /ws ke alamat di REACT_APP_API_TARGET dan memasang header skip di sisi Node. Aturan menjalankannya: REACT_APP_BASE_URL dibiarkan bernilai relatif /v1/, supaya panggilan axios ditangani dev proxy.

Ini potongan berkasnya, disederhanakan. Beberapa baris saya gabung, URL tunnel dan nilai User-Agent-nya saya samarkan, dan require yang dijaga di bagian atas berkas tidak saya tampilkan di sini karena dibahas di bawah.

// src/setupProxy.js, disederhanakan
const TARGET = process.env.REACT_APP_API_TARGET || 'https://<tunnel>'
 
function applyTunnelHeaders(proxyReq) {
  // Melewati halaman peringatan ngrok.
  proxyReq.setHeader('ngrok-skip-browser-warning', 'true')
  // Sabuk pengaman kedua: UA non-browser tidak memicu peringatan sama sekali.
  proxyReq.setHeader('User-Agent', 'dev-proxy')
}
 
module.exports = function (app) {
  if (!createProxyMiddleware) return
 
  const base = { target: TARGET, changeOrigin: true, secure: true, logLevel: 'warn' }
 
  app.use('/v1', createProxyMiddleware({ ...base, onProxyReq: applyTunnelHeaders }))
  app.use('/ws', createProxyMiddleware({ ...base, ws: true, onProxyReqWs: applyTunnelHeaders }))
}

Menurut catatan saya, header skip saja atau penggantian User-Agent saja masing-masing sudah berhasil. Keduanya tetap dipasang bersamaan supaya perubahan cara ngrok mendeteksi browser tidak bisa merusak proxy ini diam-diam, dan komentar di kodenya menyebut UA non-browser itu sabuk pengaman kedua.

Nilai bawaan TARGET adalah URL tunnel milik rekan backend, dan REACT_APP_API_TARGET bisa menimpanya. Itu berguna karena tunnel ngrok gratis mendapat URL baru setiap kali di-restart. Semua ini khusus pengembangan. Build produksi mengarahkan REACT_APP_BASE_URL ke URL absolut dan tidak pernah memuat berkas ini.

onProxyReq tidak pernah dipanggil untuk WebSocket

Dua rute di potongan tadi memakai kait yang berbeda, dan bedanya bukan soal gaya. Upgrade WebSocket butuh onProxyReqWs, bukan onProxyReq. Di http-proxy-middleware v2, jalur upgrade memancarkan proxyReqWs dan tidak pernah menjalankan jalur web. Proxy yang hanya memasang onProxyReq meloloskan handshake WebSocket tanpa header, dan handshake itu mendarat di interstitial dengan ngrok-error-code: ERR_NGROK_6024. Versi pertama proxy ini keliru persis di situ, dan baru ketahuan saat review.

Sumber pustakanya memperlihatkan kenapa. http-proxy memancarkan dua event dari dua pass yang berbeda, dan http-proxy-middleware memetakan masing-masing ke nama opsi sendiri:

// http-proxy 1.18.1, lib/http-proxy/passes/web-incoming.js
proxyReq.on('socket', function(socket) {
  // ...
  server.emit('proxyReq', proxyReq, req, res, options);
 
// http-proxy 1.18.1, lib/http-proxy/passes/ws-incoming.js
// Enable developers to modify the proxyReq before headers are sent
if (server) { server.emit('proxyReqWs', proxyReq, req, socket, options, head); }
 
// http-proxy-middleware 2.0.7, dist/_handlers.js
proxyReq: 'onProxyReq',
proxyReqWs: 'onProxyReqWs',

Event proxyReq dipancarkan di pass web, di dalam handler socket milik request keluar, dan request upgrade tidak pernah melewati pass itu. Pass WebSocket punya event sendiri, proxyReqWs, yang dipancarkan sebelum header dikirim. Kait yang terpasang di event yang salah tidak mengeluh apa-apa. Ia hanya tidak pernah dipanggil untuk handshake.

package-lock.json mengunci http-proxy-middleware di 2.0.7, termasuk pada commit perbaikan itu, dan pesan commit-nya menuliskan alasan yang sama untuk memakai onProxyReqWs. Satu-satunya commit yang menyentuh setupProxy.js sudah memuat rute /ws dengan ws: true dan onProxyReqWs: applyTunnelHeaders, jadi versi yang keliru tidak punya commit sendiri di riwayat git lokal.

Rute /ws itu hanya berguna kalau WebSocket aplikasi memang dibuka ke dev server, bukan langsung ke tunnel. Di aplikasi ini, arahnya ditentukan logika yang sudah ada sebelum proxy ini. Berkas konfigurasi WebSocket aplikasi memakai REACT_APP_WS_BASE_URL kalau variabel itu diisi. Kalau tidak, ia mengurai REACT_APP_BASE_URL dengan new URL, dan kalau itu gagal ia memakai protokol dan host dari window.location. Nilai relatif /v1/ selalu gagal diurai new URL tanpa base, jadi selama REACT_APP_WS_BASE_URL kosong, WebSocket diarahkan ke host dev server sendiri, ke path berawalan /ws, prefiks tempat rute proxy kedua dipasang. Berkas konfigurasi itu hanya disentuh dua commit, pada 25 dan 31 Agustus 2026, keduanya sebelum proxy ini ada. Yang belum pernah tercatat diuji adalah apakah rute /ws itu benar-benar menangkap handshake-nya saat aplikasi berjalan.

Dependensi yang ada karena kebetulan tata letak

Baris if (!createProxyMiddleware) return di potongan tadi ada alasannya. http-proxy-middleware tidak dideklarasikan di package.json. Menurut pesan commit-nya, modul itu ter-resolve hanya karena webpack-dev-server menariknya lalu npm mengangkatnya ke akar node_modules, dan itu kebetulan tata letak, bukan jaminan. webpack-dev-server 4.15.2 yang terpasang memang mendeklarasikan http-proxy-middleware ^2.0.3, dan versi 2.0.7 yang terkunci masuk rentang itu.

Karena itu require-nya dijaga. Kalau modulnya tidak ditemukan, setupProxy.js mencetak bahwa proxy tidak aktif beserta perintah pemasangannya, npm install --save-dev http-proxy-middleware@2, lalu keluar lewat baris tadi sebelum satu rute pun dipasang.

Di catatan proyek, http-proxy-middleware masih tercatat belum dideklarasikan dan hanya ter-resolve sebagai dependensi transitif webpack-dev-server yang diangkat. Keputusan soal itu saya pegang sendiri.

Galat tunnel yang bukan urusan frontend

Proxy itu hanya mengurus header dan origin. Tunnel di belakangnya tetap rapuh, dan sampai 10 September backend-nya memang Django lokal di balik tunnel ngrok yang berganti-ganti dan sering mati. Galatnya punya bentuk sendiri. Tunnel yang tidak bisa menjangkau backend membalas 502 dengan ERR_NGROK_8012. Saat Postgres kehabisan slot koneksi, balasannya 500 dengan halaman OperationalError. Daftar yang sama juga mencakup 504 lewat dev proxy. Tidak satu pun dari galat itu berasal dari frontend.

Pada 9 September tunnel itu mati empat kali. Dua kali karena Postgres kehabisan slot koneksi, dengan FATAL: sorry, too many clients already di port 5438. Dua kali lainnya karena tunnel-nya sendiri tidak terdaftar, dengan ERR_NGROK_3200. Tunnel itu mati permanen pukul 14:09 pada 10 September, juga dengan ERR_NGROK_3200, dan tunnel ngrok gratis yang dinyalakan lagi pun mendapat URL baru. Sejak 14 September, backend yang dipakai bekerja adalah staging. Berbeda dari tunnel lama, API staging tetap hidup, memakai satu URL, dan tidak membutuhkan header ngrok.

Aturan yang saya pegang: baca body galatnya sebelum melapor. Halaman debug Django menyebut penyebabnya dengan tepat.

Status 200 bukan bukti

Commit proxy ini, chore(dev): proxy api requests to the tunnel backend in development, tercatat pukul 18:20:04 (+0800) pada 4 September 2026, dan catatan proyek mendaftarnya sebagai proxy khusus dev yang dikirim ke cabang klien hari itu.

Resep verifikasinya juga tercatat: curl ke proxy dengan User-Agent browser dan tanpa header khusus. Buktinya adalah JSON asli yang kembali lewat localhost:3000. Status 200 saja bukan bukti, karena interstitial juga berstatus 200. Yang tidak tercatat adalah hasil menjalankan resep itu. Tidak ada tanggal, tidak ada keluaran curl, dan pesan commit-nya tidak menyebut pengujian apa pun. Jadi yang bisa saya tunjukkan di sini adalah apa yang dipasang, bukan bukti bahwa proxy itu mengembalikan JSON.

Kalau ada satu pola di seluruh cerita ini, polanya adalah kode status yang tidak bisa dibaca sendirian. Interstitial ngrok memakai 200, jadi axios menganggapnya sukses. Galat 502, 500, dan 504 dari tunnel tidak satu pun berasal dari frontend, dan untuk galat dari Django, halaman debug-nya menyebut sebabnya dengan tepat. Sebelum menyambungkan frontend ke tunnel ngrok gratis, jalankan preflight curl tadi dan baca daftar access-control-allow-headers. Kalau header skip tidak ada di sana, pindahkan lompatannya ke Node. Untuk WebSocket, kaitnya harus di onProxyReqWs, tapi itu baru syarat, dan apakah handshake-nya benar-benar lewat rute proxy tetap perlu dicek sendiri.