إشعارات Web Push في Next.js App Router: كيف نبني Service Workers ومفاتيح VAPID وتدفق أذونات آمن على iOS
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أبرز النقاط
يحتاج Web Push إلى service worker يُقدَّم من جذر النطاق، لأن نطاق عمل الـ service worker لا يمكن أن يكون أوسع من المسار الذي قُدِّم منه. في مشروع Next.js App Router يعني ذلك وضعه في public/sw.js ليقدّمه Next.js على المسار /sw.js.
VAPID (اختصار Voluntary Application Server Identification) هو زوج مفاتيح عام وخاص يُوثّق خادمك لدى خدمة الدفع في المتصفح. تغيير هذه المفاتيح يُبطل كل الاشتراكات القائمة.
يدعم Safari على iOS إصدار 16.4 فأحدث خاصية Web Push، لكن فقط للمواقع التي أضافها المستخدم إلى الشاشة الرئيسية وتعمل في وضع العرض standalone. داخل تبويب Safari عادي على iOS تكون قيمة window.PushManager غير معرّفة.
عندما تُرجع خدمة الدفع رمز HTTP 404 أو 410 فهذا يعني أن الاشتراك انتهى نهائيًا. احذفه فورًا بدل إعادة المحاولة إلى ما لا نهاية.
أرسل الإشعارات من بيئة تشغيل Node.js وليس من Edge، لأن حزمة web-push تعتمد على وحدة crypto في Node لتشفير الحمولة بخوارزمية aes128gcm.
ماذا يتطلب Web Push فعليًا في تطبيق Next.js؟
يتطلب Web Push ثلاثة أجزاء فقط: service worker مُسجَّل، وكائن PushSubscription ناتج عن استدعاء registration.pushManager.subscribe()، وخادم يوقّع طلباته بمفاتيح VAPID. لا توجد حزمة خاصة بمزوّد معيّن في المسار الأساسي — فخدمة Firebase Cloud Messaging هي نقطة نهاية الدفع الخاصة بمتصفح Chrome وليست شرطًا في البروتوكول.
نضع ملف الـ service worker دائمًا في public/sw.js ليُقدَّم على المسار /sw.js. نطاق عمل الـ service worker، أي مجموعة الصفحات التي يستطيع التحكم بها، يأخذ افتراضيًا المجلد الذي قُدِّم منه. لذلك فإن ملفًا يُقدَّم من مسار بناء متداخل مثل /_next/static/sw.js لن يتحكم عمليًا بأي صفحة.
ينسخ Next.js ملفات مجلد public كما هي دون تجميع، ما يعني أنه لا يمكنك استخدام import لأي حزمة npm داخل sw.js. يجب أن يكون الملف جافاسكربت متصفح صِرفة.
في جانب العميل نسجّل الـ service worker، ثم نستدعي Notification.requestPermission() من داخل معالج نقرة، ثم نستدعي pushManager.subscribe مع تعيين userVisibleOnly إلى true وapplicationServerKey إلى مفتاح VAPID العام. كائن PushSubscription الناتج يتحوّل إلى JSON مباشرة، فيمكن إرساله إلى مسار الاشتراك دون أي معالجة إضافية.
يقبل Chrome وFirefox نصًا بترميز base64url في applicationServerKey، بينما ما زالت بعض إصدارات Safari القديمة وبعض WebViews على أندرويد تتوقع Uint8Array، ولهذا تحتفظ أغلب الأكواد الإنتاجية بدالة مساعدة صغيرة اسمها urlBase64ToUint8Array. أما الخيار userVisibleOnly فهو إلزامي في كل المتصفحات: أنت تتعهد بعرض إشعار مرئي لكل دفعة تصلك، وChrome يُلغي الاشتراكات التي تخالف ذلك بشكل متكرر.
لماذا لا يظهر طلب الإذن على Safari في iOS؟
لا يتيح Safari على iOS واجهة Push إلا للمواقع المثبّتة على الشاشة الرئيسية. داخل تبويب Safari عادي تكون window.PushManager غير معرّفة ولا يظهر أي طلب إذن — بلا رسالة خطأ وبلا وعد مرفوض يمكن تتبّعه. وصل Web Push إلى iOS 16.4 في مارس 2023 بهذا القيد تحديدًا، وما زال القيد ساريًا.
النتيجة العملية أن واجهة طلب الإذن تحتاج مسارًا خاصًا بـ iOS. نكتشف وضع standalone عبر window.matchMedia('(display-mode: standalone)').matches، وعندما يتصفح مستخدم iOS من تبويب عادي نعرض تعليمات التثبيت بدل زر لا يفعل شيئًا. كما يجب أن يعلن ملف web app manifest قيمة display بأنها standalone، وإلا فُتح الاختصار المثبّت داخل واجهة Safari ولم يصبح مؤهلًا للإشعارات.
أضاف Safari 18.4 الصادر في مارس 2025 خاصية Declarative Web Push: يرسل الخادم حمولة JSON تحتوي على مفتاح web_push بقيمة 8030 وكائن notification، فيعرض المتصفح الإشعار دون تشغيل معالج push في الـ service worker إطلاقًا. تحسين حقيقي في الموثوقية على أجهزة Apple، لكنه إضافي: مسار الـ service worker التقليدي ما زال مطلوبًا لـ Chrome وFirefox.
كيف نرسل إشعارًا من route handler في Next.js؟
نرسل الإشعارات من route handler يعمل على بيئة Node.js باستخدام حزمة web-push التي تتولى توقيع VAPID وتشفير الحمولة بخوارزمية aes128gcm. تعيين runtime إلى edge في هذا المسار يُعطّله، لأن مسار التشفير يعتمد على وحدة crypto في Node. على Vercel تكون بيئة Node.js هي الافتراضية وتعمل على Fluid Compute، فلا حاجة لأي إعداد إضافي.
يستدعي المعالج الدالة webpush.setVapidDetails مرة واحدة مع بريد تواصل والمفتاح العام والمفتاح الخاص، ثم يمرّ على اشتراكات المستخدم المخزّنة عبر webpush.sendNotification داخل Promise.allSettled. استخدام allSettled بدل Promise.all مهم: نقطة نهاية ميتة واحدة يجب ألا توقف التسليم لبقية الدفعة.
يجب أن تبقى الحمولة صغيرة. تضمن خدمات الدفع نحو 4 كيلوبايت فقط من الحمولة المشفّرة، ويستهلك التشفير جزءًا من هذه المساحة. النمط الذي يصمد في الإنتاج هو إرسال معرّف وعنوان قصير، ثم جلب السجل الكامل عند نقر المستخدم. كما أن محتوى الحمولة يبقى على خادم دفع تابع لطرف ثالث حتى التسليم، وهذا سبب ثانٍ لإبقائه محدودًا.
داخل الـ service worker، يستدعي معالج حدث push الدالة event.waitUntil حول self.registration.showNotification، بينما يستخدم معالج notificationclick الدالة clients.matchAll مع type بقيمة window وincludeUncontrolled بقيمة true حتى يُركَّز على تبويب مفتوح مسبقًا بدل فتح تبويب مكرر.
متى يجب حذف اشتراك الإشعارات؟
احذف الاشتراك فور أن تُجيب خدمة الدفع برمز 404 أو 410 Gone، فهذان الرمزان يعنيان أن نقطة النهاية لن تعود صالحة أبدًا. هذه أهم خطوة نظافة في أي نظام إشعارات، لأن الاشتراكات تموت باستمرار وبصمت عندما يمسح المستخدمون بيانات الموقع أو يعيدون تثبيت المتصفح أو يسحبون الإذن، دون أن يُخطر خادمك بشيء.
بقية الرموز تحتاج معالجة مختلفة. الرمز 413 يعني أن الحمولة تجاوزت الحد المسموح لحجمها. الرمز 429 يعني أنك مُقيَّد بمعدل الإرسال ويجب احترام ترويسة Retry-After بدل تكرار المحاولة. أما 401 أو 403 فيعني غالبًا أن مفاتيح VAPID لم تعد مطابقة للمفاتيح المستخدمة وقت الاشتراك، ولهذا فإن تدوير مفاتيح VAPID يجبر كل المستخدمين على إعادة الاشتراك.
قد تُغيّر المتصفحات نقطة النهاية من تلقاء نفسها فتُطلق حدث pushsubscriptionchange داخل الـ service worker. يطلقه Chrome بثبات، بينما الدعم في غيره متفاوت. نتعامل معه كمسار إضافي ونعتمد على حذف اشتراكات 410 وإعادة الاشتراك في الزيارة التالية كآلية تعافٍ حقيقية.
Web Push مقابل SSE مقابل WebSockets: أيهما نختار؟
Web Push هو الوحيد من بين الثلاثة الذي يعمل والموقع مغلق. أما Server-Sent Events وWebSockets فيحتاجان صفحة مفتوحة، لذا فالسؤال الحاسم هو: هل المستخدم حاضر الآن أم لا؟
Web Push: يعمل والتبويب مغلق، اتجاه واحد من الخادم إلى الجهاز، وأنسب لإعادة جذب المستخدم والتنبيهات التي لا يصح أن تفوته.
Server-Sent Events (SSE): يحتاج صفحة مفتوحة، اتجاه واحد من الخادم إلى الصفحة، وأنسب لبثّ مخرجات الذكاء الاصطناعي ومؤشرات التقدم والتحديثات الحية.
WebSockets: يحتاج صفحة مفتوحة، اتجاهان، وأنسب للتحرير التشاركي والمحادثات وحالة الحضور.
هذه الوسائل تتكامل جيدًا. في الأنظمة التي نبنيها ينقل SSE التحديثات الحية أثناء فتح التبويب، بينما تُطلق مهمة خلفية إشعار Web Push فقط إذا لم تكن للمستخدم جلسة نشطة لعدة دقائق. هذه القاعدة وحدها تمنع وصول الحدث نفسه مرتين.
ما الذي ينكسر في الإنتاج ولا يظهر أثناء التطوير المحلي؟
أغلى الأعطال هو service worker قديم عالق. يحتفظ المتصفح بنسخة sw.js السابقة حتى تتغير بايتات الملف ويكتمل تثبيت النسخة الجديدة، لذا قد يبقى معالج push مُصلَح بلا استخدام على أجهزة حقيقية ليوم كامل. استدعاء self.skipWaiting() في حدث install وclients.claim() في activate يقصّر هذه الفترة، وتسجيل رقم إصدار من داخل الـ worker يخبرك أي بناء تعامل فعليًا مع الإشعار.
العطل الثاني هو تجربة طلب الإذن. لا يمكن استدعاء Notification.requestPermission() إلا من تفاعل مباشر للمستخدم، وبمجرد اختياره الحظر لا يمكن إظهار الطلب مرة أخرى عبر جافاسكربت على هذا النطاق. طلب الإذن عند تحميل الصفحة يحرق الفرصة الوحيدة المتاحة، لذلك نربطه بزر تفعيل صريح يظهر بعد أن يقوم المستخدم بفعل يجعل الإشعارات مفيدة بوضوح.
العطل الثالث هو التوزيع الواسع. الإرسال إلى بضع مئات من الاشتراكات داخل معالج طلب أمر مقبول، أما عشرات الآلاف فلا، لأن كل استدعاء sendNotification هو رحلة HTTPS منفصلة إلى خدمة طرف ثالث. هذا العمل مكانه طابور مهام أو مهمة خلفية تُعالَج على دفعات مع Promise.allSettled.
العطل الرابع أن localhost يخدعك. يُسمح بتشغيل service workers وواجهة Push على http://localhost كاستثناء للسياق الآمن، فيعمل كل شيء محليًا ثم يفشل على خادم تجريبي يعمل بـ HTTP عادي. يجب اختبار الإشعارات على نطاق HTTPS حقيقي وهاتف حقيقي قبل الوثوق بها.
الأسئلة الشائعة
س: هل نحتاج Firebase Cloud Messaging لإرسال Web Push؟
ج: لا. FCM هي نقطة نهاية الدفع الخاصة بـ Chrome، لكن بروتوكول Web Push مع VAPID يتيح لخادمك الإرسال مباشرة إلى أي نقطة نهاية يعطيها المتصفح. وحزمة web-push على npm تتحدث هذا البروتوكول مع نقاط نهاية Chrome وFirefox وSafari على حد سواء.
س: هل يمكن إرسال إشعار صامت دون عرض إشعار مرئي؟
ج: لا على الويب المفتوح. تعيين userVisibleOnly إلى true إلزامي في Chrome وFirefox وSafari، وتكرار استقبال دفعة دون عرض إشعار قد يؤدي إلى إلغاء الاشتراك.
س: لماذا توقفت كل الاشتراكات عن العمل بعد النشر؟
ج: السبب غالبًا تغيير مفاتيح VAPID. المفتاح العام مُضمَّن داخل كل كائن PushSubscription قائم، فأي زوج مفاتيح جديد يجعل الاشتراكات المخزّنة تفشل في التوثيق ويضطر كل مستخدم إلى إعادة الاشتراك.
س: كيف نختبر Web Push دون انتظار حدث حقيقي؟
ج: توفّر أدوات مطوري Chrome حقل Push ضمن Application ثم Service Workers يرسل حمولة مباشرة إلى معالج push في الـ worker. هذا يختبر العرض فقط ويتخطى VAPID والتشفير، لذا ينبغي أيضًا استدعاء مسار الإرسال على اشتراكك الشخصي لاختبار مسار الخادم.
س: هل يعمل Web Push في تطبيق Next.js مثبّت على الشاشة الرئيسية في iOS؟
ج: نعم. يعتبر iOS تطبيق PWA المثبّت مؤهلًا بغض النظر عن إطار العمل، شرط أن يضبط الـ manifest قيمة display على standalone وأن يأتي طلب الإذن من تفاعل مباشر داخل التطبيق المثبّت.
قراءات إضافية
هندسة الويب المتكاملة
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر --heapsnapshot-signal، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.
هندسة الويب المتكاملة
لماذا لا تنجح ذاكرة 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 وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.