Ingress AnyHook adalah sebuah Cloudflare Worker. Setiap webhook masuk yang diterima layanan ini mendarat di sana lebih dulu, diverifikasi tanda tangannya (signature), disimpan secara persisten, dan pengirimnya mendapat respons 200 dalam sekitar satu detik, sebagian besar waktunya habis untuk perjalanan pulang-pergi ke region antrean. Sistem ini sudah cukup lama berjalan di production, dan hal-hal yang ternyata merepotkan bukanlah yang saya perkirakan.
Artikel ini adalah pendamping dari sisi penerima untuk Menerima Webhook di Vercel tanpa Ada yang Hilang. Mode kegagalannya benar-benar berbeda, dan sebagian besar berasal dari kenyataan bahwa Workers bukan Node.
TL;DR
node:cryptotidak tersedia secara default. Verifikasi tanda tangan berjalan lewat WebCrypto, yang bersifat async- Baca body sekali, sebagai byte, sebelum ada yang mengurainya, sama seperti di mana pun
- Terapkan batas ukuran payload Anda sendiri selama streaming, karena membaca body besar hanya untuk mengetahui bahwa body itu besar adalah urutan yang salah
ctx.waitUntilmemperpanjang permintaan melewati respons tetapi tidak persisten, persis seperti milik Vercel- Jatah CPU kecil di paket Workers Free dan terpisah dari waktu nyata (wall-clock). Menunggu API yang lambat tidak menghabiskannya; loop ketat atas payload besar menghabiskannya
- Worker adalah tempat yang sangat baik untuk mengonfirmasi penerimaan dan menyimpan secara persisten, dan tempat yang buruk untuk logika bisnis Anda
Verifikasi tanda tangan bersifat async, dan itu mengubah alur kontrol Anda
Di Node, Anda menulis verifier sinkron dan memanggilnya di awal. Method WebCrypto mengembalikan promise, jadi logika yang sama menjadi async, dan godaannya adalah menunggu hasilnya setelah Anda telanjur mengurai body untuk routing. Jangan. Verifikasi harus dilakukan sebelum apa pun yang memercayai payload.
Berikut bentuk yang dipakai ingress kami untuk mendekripsi secret provider yang tersimpan sebelum memakainya untuk verifikasi. Semuanya crypto.subtle:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
"AES-GCM",
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
keyMaterial,
dataToDecrypt,
);
Dua detail yang menyita waktu jika Anda baru menemukannya setelah terkena masalah.
AES-GCM di WebCrypto mengharapkan auth tag ditempelkan di akhir ciphertext, sedangkan createDecipheriv di Node menerimanya terpisah lewat setAuthTag. Jika Anda mengenkripsi di Node dan mendekripsi di Worker, Anda harus menggabungkan keduanya sendiri. Tidak ada yang memberi tahu Anda soal ini; Anda hanya mendapat OperationError.
WebCrypto tidak punya timingSafeEqual standar. Untuk verifikasi HMAC, utamakan crypto.subtle.verify, yang melakukan perbandingan untuk Anda dalam waktu konstan, alih-alih menghitung digest lalu membandingkan string.
Anda bisa menyalakan nodejs_compat dan mendapatkan shim node:crypto, dan untuk HMAC yang sederhana itu jalan pintas yang masuk akal. Namun shim tersebut hanyalah lapisan kompatibilitas di atas primitive yang sama, dan membangun langsung di atas WebCrypto berarti kode verifikasi juga berjalan tanpa perubahan di Deno, Bun, dan browser.
Baca body sebagai byte, dan berhenti lebih awal jika terlalu besar
Aturan raw body berlaku universal: HMAC mencakup byte persis yang tiba, jadi jangan mengurai apa pun sebelum Anda memverifikasi. await request.text() atau await request.arrayBuffer() lebih dulu.
Yang khas pada penerima di edge adalah Anda menjadi pintu depan bagi siapa pun di internet yang punya URL Anda, dan menampung body sebesar apa pun di buffer sebelum memutuskan bahwa Anda tidak menginginkannya adalah cara Anda ditagih untuk trafik orang lain. Ingress kami memeriksa content-length lebih dulu, lalu menerapkan batas itu lagi selama streaming, karena content-length adalah klaim dari klien, bukan fakta:
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;
const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limitBytes) {
reader.cancel(); // stop pulling bytes we have decided to reject
return null;
}
chunks.push(value);
}
reader.cancel() adalah bagian yang layak ditiru. Tanpanya, Anda tetap menyelesaikan unduhan payload yang sudah Anda putuskan untuk ditolak.
Gerbang termurah dipasang paling depan
Penerima di edge adalah endpoint publik, dan urutan pemeriksaan Anda adalah keputusan biaya sekaligus keputusan keamanan.
Urutan kami: tolak permintaan yang tidak membawa header yang selalu dikirim provider yang diakuinya, lalu verifikasi tanda tangan jika secret sudah dikonfigurasi, lalu hitung event itu ke kuota. Permintaan tanpa header Stripe-Signature yang mengaku sebagai webhook Stripe ditolak sebelum kriptografi apa pun berjalan dan sebelum apa pun dihitung. Penyerang yang tidak membawa header yang benar praktis tidak menghabiskan apa-apa.
Urutan itu hanya berhasil jika pemeriksaan yang murah benar-benar murah. Keberadaan header cukup dicek dengan lookup map. Verifikasi tanda tangan berarti impor kunci ditambah satu HMAC. Kuota berarti satu pembacaan KV. Susun dalam urutan itu, dan operasi yang mahal hanya berjalan untuk trafik yang sudah layak mendapatkannya.
Di bawah semua itu ada rate limit, per IP dan per App (satu URL masuk untuk satu sumber webhook), ditambah negative cache singkat untuk slug yang tidak ada, agar pemindaian endpoint yang valid tidak berubah menjadi tagihan.
waitUntil bukan antrean, sekali lagi
ctx.waitUntil menjaga Worker tetap hidup setelah respons agar pekerjaan latar belakang bisa selesai. Fitur ini benar-benar berguna, tetapi bukan persistensi.
Jika isolate-nya dikeluarkan dari memori atau permintaannya dibatalkan di tengah jalan, pekerjaan itu hilang. Tidak ada percobaan ulang (retry), tidak ada dead letter, tidak ada catatan. Dan Anda sudah mengembalikan 200, jadi pengirim menganggap event itu terkirim dan tidak akan pernah mengirimnya lagi. Event itu kini tidak ada di mana pun.
Ini jebakan yang sama persis dengan waitUntil di Vercel, dan aturan yang sama berlaku: simpan secara persisten sebelum Anda mengonfirmasi. Tulis event di tempat yang persisten, kembalikan 200, lalu proses.
Kami memakai waitUntil hanya untuk pekerjaan yang boleh hilang, seperti menaikkan counter. Jika counter penolakan kurang tiga hitungan saat terjadi eviction, tidak ada yang dirugikan. Jika payload event hilang, ada yang dirugikan.
Waktunya juga pendek. Cloudflare membatalkan pekerjaan waitUntil 30 detik setelah respons, dibagi untuk semua promise yang Anda serahkan kepadanya, dan satu panggilan model saja bisa melampaui itu. Handler Webhook Anda Memanggil LLM membahas apa yang sebaiknya dipakai di setiap platform.
Untuk apa pun yang harus bertahan, pilihannya adalah antrean, penyimpanan persisten, atau keduanya. Ingress kami memublikasikan event ke antrean terkelola; jika publikasi itu gagal, event ditampung ke R2, dan jika itu juga gagal, event diparkir di Postgres. Jika ketiganya gagal, kami mengembalikan 503, bukan 200, sehingga percobaan ulang dari pengirim sendiri menjadi garis pertahanan terakhir. Keputusan itu layak dinyatakan secara eksplisit: mengembalikan 200 untuk event yang tidak bisa Anda simpan di mana pun adalah kebohongan yang membuat Anda kehilangan event itu.
Waktu CPU bukan waktu nyata
Workers menagih dan membatasi waktu CPU, bukan waktu yang dihabiskan permintaan Anda untuk menunggu. Ini membingungkan orang yang terbiasa dengan Lambda, karena di sana keduanya sama.
Menunggu penulisan database atau API upstream tidak memakai CPU. Mengurai payload JSON berukuran dua megabyte memakainya. Melakukan hash pada body besar memakainya. Loop atas sepuluh ribu elemen array memakainya.
Bagi penerima webhook, sebagian besar ini kabar baik, karena bentuk yang Anda inginkan memang hampir seluruhnya I/O: baca byte, verifikasi, tulis, respons. Operasi yang akan menjebak Anda adalah yang ditambahkan orang tanpa berpikir, seperti JSON.parse pada payload yang hanya perlu Anda simpan, atau regex atas seluruh body.
Jika Anda hanya perlu melakukan routing berdasarkan header dan menyimpan byte-nya, jangan mengurai body sama sekali. Simpan sebagai byte dan biarkan consumer downstream, yang punya jatah CPU sungguhan, yang mengurainya.
Apa yang cocok di edge dan apa yang tidak
Setelah menjalankan ini di production, garis batasnya lebih jelas daripada di awal.
Cocok di Worker: terminasi TLS di dekat pengirim, menolak sampah, memverifikasi tanda tangan, menyimpan payload secara persisten, memublikasikan ke antrean, mengembalikan 200 dengan cepat. Semuanya I/O-bound, semuanya murah, semuanya sensitif terhadap latensi sehingga benar-benar diuntungkan oleh kedekatan dengan pemanggil.
Tidak cocok di Worker: logika bisnis Anda. Apa pun yang butuh connection pool berumur panjang, pohon dependensi yang besar, CPU berat, atau ekosistem Node. Bukan karena tidak bisa dibuat berjalan, tetapi karena Anda akan melawan platform tanpa manfaat apa pun, dan inti dari menaruh penerima di edge adalah agar bagian yang mahal terjadi di tempat lain sesuai jadwalnya sendiri.
Pemisahan itu, konfirmasi di edge dan pengiriman secara asinkron, adalah arsitektur yang menjadi dasar AnyHook, bukan argumen yang kami karang belakangan.
Jebakan deploy yang perlu Anda ketahui
Jika Anda menjalankan beberapa environment dalam satu wrangler.toml, wrangler deploy tanpa --env production akan merilis environment default. Worker production Anda tetap melayani kode sebelumnya dan tidak ada error, karena deploy-nya memang berhasil, hanya saja bukan di tempat yang Anda maksud.
Kami kehilangan satu sore gara-gara ini. Secret punya pola yang sama: wrangler secret put NAME --env production, atau Anda memasangnya di tempat yang tidak melayani trafik.
Kesimpulan
Worker adalah penerima webhook yang sangat baik selama Anda menerimanya apa adanya: pintu depan yang I/O-bound, tanpa Node, tanpa persistensi di waitUntil, dan dengan jatah CPU yang menghukum penguraian yang tidak Anda perlukan.
Baca byte sebelum ada yang mengurainya, batalkan stream saat payload melewati batas Anda, dahulukan penolakan yang murah sebelum yang mahal, dan jangan pernah mengembalikan 200 untuk sesuatu yang belum Anda tulis secara persisten. Semua yang lain tempatnya di downstream, yang punya waktu.