تخطّى إلى المحتوى الرئيسي
الويب هوكس في Next.js App Router: الجسم الخام للطلب، والتحقق من التوقيع، وإرجاع 200 بسرعة

الويب هوكس في Next.js App Router: الجسم الخام للطلب، والتحقق من التوقيع، وإرجاع 200 بسرعة

August 15, 2026
هندسة الويب المتكاملة
6 دقيقة قراءة

الويب هوك هو طلب HTTP POST يرسله مزود الخدمة إلى خادمك عندما يحدث شيء لديه — تسوية دفعة، أو دفعة كود إلى مستودع، أو إلغاء اشتراك. ربط فريقنا هذا العام الويب هوكس بعدة تطبيقات Next.js App Router، للمدفوعات وتكاملات استضافة Git، وكل تكامل تعطل في المواضع الثلاثة نفسها أولاً: التحقق من التوقيع على جسم طلب تم تحليله مسبقاً، وتنفيذ عمل كثير قبل الرد، ومعالجة الحدث نفسه مرتين. هذه هي الملاحظات الميدانية التي نبدأ منها الآن كل تكامل ويب هوك.

أهم الخلاصات

- تواقيع الويب هوك — ترويسة Stripe-Signature لدى Stripe وترويسة X-Hub-Signature-256 لدى GitHub — هي HMAC محسوبة على بايتات الطلب الخام. تحقق منها باستخدام await req.text()، وليس أبداً على ناتج JSON.parse بعد إعادة تسلسله.

- معالجات المسارات في Next.js App Router لا تحلل جسم الطلب مسبقاً، لذا فإن خيار bodyParser: false الخاص بـ Pages Router غير ضروري — req.text() تعيد الحمولة الخام مباشرة.

- أعد استجابة 2xx بسرعة. يعتبر المزود الاستجابة البطيئة تسليماً فاشلاً ويعيد المحاولة، فينتهي الأمر بالمعالج البطيء متسابقاً مع إعادة محاولاته.

- تسليم الويب هوك يتم مرة واحدة على الأقل وبدون ترتيب: أزل التكرار عبر معرف الحدث بقيد فريد في قاعدة البيانات، واجلب الكائن الحالي من واجهة برمجة تطبيقات المزود بدلاً من الوثوق بحالة الحمولة.

- قارن التواقيع باستخدام crypto.timingSafeEqual وافرض حداً زمنياً للطابع الزمني حتى لا يمكن إعادة إرسال طلب ملتقط لاحقاً.

لماذا يفشل التحقق من التوقيع على طلب سليم؟

لأن التوقيع حُسب على البايتات الدقيقة التي أرسلها المزود، بينما يتحقق المعالج من بايتات مختلفة. توقيع الويب هوك هو تجزئة بمفتاح — HMAC — لجسم الطلب الخام، منتجة بسر توقيع مشترك. إذا استدعى المعالج JSON.parse على الجسم ثم أعاد تسلسله للتحقق، فقد يتغير ترتيب المفاتيح والمسافات وترميز اليونيكود، فلا يتطابق HMAC رغم أن الطلب حقيقي. الفشل صامت وكامل: كل حدث يحصل على 400، والمزود يعيد المحاولة، وطابور إعادة المحاولات يمتلئ بينما يبدو الكود صحيحاً.

يجعل App Router النسخة الصحيحة سهلة. يستقبل معالج المسار كائن Request القياسي، و Next.js لا يحلله مسبقاً — await req.text() تعيد الحمولة بايتاً ببايت. كان Pages Router يحتاج إلى تصدير bodyParser: false للوصول نفسه؛ هذا الإعداد لا يفعل شيئاً في App Router وغير مطلوب.

كيف نتحقق من توقيع الويب هوك داخل معالج المسار؟

عندما يوفر المزود أداة تحقق جاهزة، نستخدمها. تفحص stripe.webhooks.constructEvent(rawBody, signatureHeader, secret) كلاً من HMAC والطابع الزمني الموقع في استدعاء واحد وترمي استثناءً عند أي فشل، فيعيد المعالج 400 من كتلة catch و 200 فيما عدا ذلك.

بدون أداة جاهزة، نحسب HMAC بأنفسنا عبر createHmac('sha256', secret).update(rawBody).digest('hex') ونقارنه بقيمة الترويسة. قاعدتان غير قابلتين للتفاوض. أولاً، قارن باستخدام crypto.timingSafeEqual بعد فحص أطوال الـ Buffer، لأن مقارنة النصوص العادية تتوقف عند أول حرف مختلف وتسرب معلومات توقيت يمكن للمهاجم استغلالها لاستكشاف التوقيع بايتاً ببايت. ثانياً، افرض حداً زمنياً للطابع الزمني — معظم المزودين يضمنون طابعاً زمنياً موقعاً، ورفض أي طلب أقدم من نحو خمس دقائق يمنع إعادة إرسال طلب ملتقط لاحقاً.

ملاحظة بنيوية: نقطة نهاية الويب هوك يجب أن تكون معالج مسار وليس Server Action. الـ Server Action آلية استدعاء عن بعد لواجهة تطبيقك أنت، تُعنون بمعرفات يولدها إطار العمل. الويب هوك يحتاج عنوان POST عاماً وثابتاً مع وصول إلى الجسم على مستوى البايت، ومعالج المسار في app/api/webhooks/provider/route.ts هو ذلك بالضبط.

لماذا يجب أن يعيد المعالج 200 قبل تنفيذ العمل الفعلي؟

لأن المزود يعتبر الاستجابة البطيئة تسليماً فاشلاً. مهل التسليم تقاس بالثواني، والمعالج الذي يتجاوزها يُعلَّم فاشلاً ويعاد إرساله — فينتهي المعالج البطيء بالعمل بالتوازي مع إعادة محاولته. منطق تنفيذ يستغرق عشر ثوانٍ يضمن وصول كل حدث حقيقي مرتين على الأقل.

الشكل الذي نشحنه الآن: تحقق، سجل، أكد الاستلام، ثم اعمل. على Vercel، تبقي waitUntil من @vercel/functions نسخة الدالة حية بعد إرسال الاستجابة، فيسجل المعالج معرف الحدث، ويستدعي waitUntil(processEvent(event))، ويعيد 200 خلال أجزاء من الثانية.

هناك ثلاثة أماكن يمكن أن يعمل فيها العمل الفعلي. داخل المعالج قبل الاستجابة: التأكيد بطيء، لكن الانهيار تغطيه إعادة محاولة المزود — مقبول فقط للعمل التافه مثل ضبط علامة. داخل waitUntil بعد الاستجابة: التأكيد سريع، لكن العمل المفقود في انهيار سبق تأكيده بـ 200 ولن يعاد إرساله أبداً — مقبول للآثار الجانبية القابلة للفقد مثل تسخين الكاش والإشعارات. في طابور أو صف مهمة يعالجه عامل منفصل: التأكيد سريع والمهمة تنجو من الانهيارات وعمليات النشر — الخيار الوحيد الذي نثق به للمال والاستحقاقات وأي حالة لا يمكن فقدها.

كيف نتعامل مع إعادة المحاولات والأحداث خارج الترتيب؟

بافتراض التسليم مرة واحدة على الأقل وبدون ترتيب، لأن كليهما سلوك موثق لدى المزودين وليس حالة نادرة. إعادة المحاولات تنتج تكرارات بحكم التصميم، وحدثان متتاليان قد يصلان معكوسين.

إزالة التكرار: كل حدث من المزود يحمل معرفاً ثابتاً — معرف evt_ لحدث Stripe، وترويسة X-GitHub-Delivery لتسليم GitHub. ندرج هذا المعرف في جدول بقيد فريد قبل المعالجة؛ إذا خالف الإدراج القيد فالحدث عولج من قبل، فيعيد المعالج 200 ويتوقف. فحص SELECT ثم INSERT سباق تحت إعادة المحاولات المتزامنة — القيد الفريد هو القفل.

الترتيب: لا نبني الحالة بتطبيق الحمولات حسب ترتيب الوصول. الحدث إشعار بأن شيئاً تغير، وليس التغيير نفسه. لأي شيء ذي حالة نجلب الكائن الحالي من واجهة برمجة تطبيقات المزود قبل الكتابة، حتى لا تتمكن حمولة قديمة من الكتابة فوق حالة أحدث.

كيف نختبر الويب هوكس محلياً؟

لا يستطيع المزود الوصول إلى localhost، لذا يحتاج الاختبار المحلي جسراً. ثلاثة خيارات تغطي عملنا: أداة سطر أوامر من المزود تعيد توجيه الأحداث — stripe listen --forward-to localhost:3000/api/webhooks/stripe تطبع سر توقيع مؤقتاً للجلسة؛ ونفق مثل cloudflared أو ngrok مع زر إعادة التسليم اليدوي في لوحة المزود؛ وعينات موقعة في الاختبارات الآلية.

العينات الموقعة هي الأعلى قيمة. نلتقط حمولة حقيقية واحدة، ونحسب HMAC لها بسر اختباري، ونتحقق من ثلاثة أشياء: المدقق يقبل الزوج الصحيح، ويرفض جسماً معدلاً، ويرفض طابعاً زمنياً منتهياً. هذا الاختبار يلتقط انحدار الجسم الخام — شخص يضيف وسيطاً لتحليل JSON أو ينقل التحليل فوق التحقق — قبل أن يُشحن ويرفض كل حدث بصمت.

الأسئلة الشائعة

**س:** هل يمكن استخدام Server Action كنقطة نهاية ويب هوك؟

**ج:** لا. يُستدعى الـ Server Action عبر معرفات يولدها إطار العمل وهو مصمم لمكونات تطبيقك أنت. مزود الويب هوك يحتاج عنوان POST عاماً وثابتاً مع وصول إلى الجسم الخام، وهذا هو معالج المسار.

**س:** هل ما زال bodyParser: false مطلوباً في App Router؟

**ج:** لا. هذا الخيار يخص مسارات API في Pages Router. معالج المسار في App Router يترك الجسم كما هو حتى تستدعي req.text() أو req.json() أو req.formData()، فالحمولة الخام متاحة دائماً.

**س:** ماذا يجب أن يعيد المعالج لأنواع الأحداث التي لا يعالجها؟

**ج:** أعد 200. الاستجابة غير 2xx تخبر المزود بفشل التسليم فيعيد إرسال أحداث لن تُعالج أبداً، وبعض المزودين يعطل نقطة نهاية تستمر في الفشل. احتفظ بـ 400 لفشل التحقق من التوقيع.

**س:** ماذا يحدث للأحداث المرسلة أثناء توقف النشر؟

**ج:** يعيد المزودون إرسال التسليمات الفاشلة بتراجع تدريجي — Stripe يعيد المحاولة لأيام — فالتوقف القصير يُمتص عادة. للحالة الحرجة نشغل أيضاً مهمة تسوية دورية تسرد الكائنات الحديثة من واجهة برمجة تطبيقات المزود وتصلح ما فاته أي ويب هوك.

**س:** كيف يُبدَّل سر الويب هوك دون توقف؟

**ج:** تحقق من التواقيع الواردة مقابل السر القديم والجديد معاً خلال نافذة التبديل. مزودون مثل Stripe يسمحون ببقاء سر التوقيع القديم فعالاً لفترة تداخل لهذا السبب بالضبط.

قراءات إضافية

لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
اقرأ المقال

هندسة الويب المتكاملة

لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ

خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر ‎--heapsnapshot-signal‎، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
اقرأ المقال

هندسة الويب المتكاملة

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate

كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.