تطبيق المحادثة على هاتفك مغلق، والشاشة مطفأة منذ ساعة، ومع ذلك تصل الرسالة الجديدة في ثانية، ويظهر تنبيه البنك قبل أن تخرج من المتجر، ويخبرك المتصفح أن طلبك قد شحن وأنت لم تفتح الموقع منذ أيام. وقد يتساءل البعض: من الذي أيقظ التطبيق؟ وهل يسأل كل تطبيق سيرفره كل دقيقة؟ والإجابة أن لا أحد منها يسأل، وإنما يحافظ نظام التشغيل أو المتصفح على اتصال واحد مفتوح مع خدمة تديرها Apple أو Google أو Mozilla، وكل سيرفر في العالم يريد أن يكلم تطبيقه على هاتفك يمر عبر هذه الخدمة، وهذا ما يسمى الإشعارات الفورية Push Notifications.
وهذا أول مقال في مرحلة الإشعارات والتنبيهات في خارطة الطريق، والهدف منه أن تفهم الطريق الذي تسلكه الرسالة قبل أن تختار أداة، لأن هذا الطريق هو الذي يحدد ما تستطيع أن تستضيفه بنفسك وما لا تستطيع، وما الذي يراه طرف ثالث من بيانات مستخدميك. وسوف نتناول في هذا المقال ما يلي:
- لماذا لا يسأل التطبيق سيرفره كل دقيقة، وما الذي تحله خدمة الإشعارات.
- الفكرة المشتركة بين كل الخدمات: الاتصال الدائم والتوكن Token.
- كيف تعمل خدمة Apple واختصاراً APNs، وخدمة Google واختصاراً FCM، والمعيار المفتوح Web Push مع VAPID.
- ما الذي يمر عبر سيرفرات Google وApple، وكيف تحمي خصوصية مستخدميك.
- ما الذي تستطيع استضافته بنفسك فعلاً، وما الذي لا بديل له.
- تجربة عملية: إشعار Web Push حقيقي من سيرفر على جهازك إلى Chrome، ثم نرى ما تستلمه خدمة الإشعارات بالبايت، ونكسر التجربة عن قصد.
لماذا لا يسأل التطبيق سيرفره كل دقيقة؟
الحل البسيط الذي يخطر على البال أولاً هو السؤال المتكرر Polling: يرسل التطبيق كل ثلاثين ثانية طلباً إلى سيرفره «هل عندك شيء جديد؟» ويأخذ الرد. وهذا يعمل على حاسوب مكتبي موصول بالكهرباء، أما على الهاتف فيفشل لثلاثة أسباب. الأول البطارية، لأن كل طلب يوقظ شريحة الاتصال اللاسلكي Radio ويبقيها نشطة ثواني بعده، فإذا كان على الهاتف عشرون تطبيقاً يسأل كل منها بطريقته فإن الشريحة لا تنام أبداً. والثاني التأخير، فالرسالة التي تصل إلى السيرفر بعد السؤال بثانية تنتظر تسعاً وعشرين ثانية حتى السؤال التالي، وإذا قصرت المدة زادت المشكلة الأولى. والثالث أن أنظمة التشغيل نفسها تمنع ذلك، فنظام iOS لا يترك التطبيق يعمل في الخلفية إلا في حالات محددة، وAndroid يؤجل وصول التطبيقات إلى الشبكة في وضع توفير الطاقة Doze كما في توثيق Android، فالتطبيق المغلق لا يستطيع أن يسأل في الوقت الذي يريده.
والحل الثاني أن يفتح كل تطبيق اتصالاً دائماً مع سيرفره، وهو أفضل من السؤال المتكرر لأن الرسالة تصل فوراً، ولكنه يبقى عشرين اتصالاً بدل واحد، وكل اتصال منها يحتاج إلى رسالة صغيرة كل بضع دقائق تبقيه حياً، لأن ال Router وشبكة المزود ينسون الاتصال الذي لا تمر فيه بيانات، كما شرحنا في تتبع الاتصالات Connection Tracking. والحل الذي استقرت عليه كل الأنظمة هو أن يحافظ النظام نفسه على اتصال واحد فقط مع خدمة إشعارات واحدة، وكل التطبيقات تستقبل رسائلها عبره، والصورة التالية تبين الفرق:

لاحظ أن هذا الاتصال الواحد لا يملكه تطبيقك ولا سيرفرك، وإنما يملكه مصنع النظام أو المتصفح، وهذه هي النقطة التي يدور حولها باقي المقال.
الفكرة المشتركة: الاتصال الدائم والتوكن Token
كل خدمات الإشعارات، على اختلاف أسمائها، تعمل بالخطوات نفسها، والصورة التالية تبينها:

في الصورة أعلاه لاحظ التالي:
- التسجيل Registration: عندما يوافق المستخدم على الإشعارات يطلب التطبيق من النظام تسجيله، والنظام يكلم خدمة الإشعارات عبر اتصاله الدائم.
- التوكن Token: ترد الخدمة بقيمة طويلة تمثل «هذا التطبيق على هذا الجهاز»، واسمها يختلف بحسب الخدمة: Device Token عند Apple، وRegistration Token عند Google، وEndpoint عند Web Push، ولكن الفكرة واحدة، فهو عنوان بريدي للتطبيق داخل خدمة الإشعارات.
- إرسال التوكن إلى سيرفرك: يرسل التطبيق التوكن إلى سيرفرك عبر HTTPS مع ما يعرف المستخدم، وسيرفرك يحفظه في قاعدة البيانات، والمستخدم الواحد قد يكون له عدة توكنات لأن له عدة أجهزة.
- الإرسال: عندما يحدث ما يستحق التنبيه يرسل سيرفرك طلب HTTP إلى خدمة الإشعارات يحمل التوكن والرسالة وما يثبت أنه سيرفرك فعلاً.
- التوصيل Delivery: الخدمة تجد الجهاز صاحب التوكن وترسل الرسالة عبر الاتصال المفتوح، وإذا كان الجهاز مطفأ تحفظها مدة محددة ثم تحذفها.
والنتيجة المهمة لهذا التصميم أن سيرفرك لا يكلم الهاتف أبداً ولا يعرف عنوانه، وأن التوكن يتغير، فعليك أن تحدثه في قاعدة بياناتك كلما أرسله التطبيق، وأن تحذفه عندما ترد الخدمة بأنه لم يعد صالحاً، كما سوف ترى في التجربة العملية.
خدمة Apple للإشعارات APNs
كل إشعار يصل إلى iPhone أو iPad أو Mac أو Apple Watch يمر عبر خدمة Apple Push Notification service واختصاراً APNs، ولا يوجد طريق آخر إلى تطبيق مغلق على iOS. وبحسب توثيق Apple في إرسال طلبات الإشعارات إلى APNs يتصل سيرفرك ب HTTP/2 وTLS 1.2 أو أحدث بأحد سيرفرين: api.sandbox.push.apple.com للتطوير وapi.push.apple.com للإنتاج، على المنفذ 443 أو المنفذ 2197 إذا أردت أن تسمح في جدار الحماية Firewall بحركة APNs وحدها دون باقي HTTPS. والطلب نفسه POST إلى المسار /3/device/<device_token> وجسمه JSON يحمل نص الإشعار، مع ترويسات Headers منها apns-push-type لنوع الإشعار وapns-topic لمعرف التطبيق وapns-expiration لمدة الحفظ.
ويثبت سيرفرك هويته لخدمة Apple بإحدى طريقتين: شهادة TLS خاصة بالتطبيق، أو التوثيق بالتوكن Token-Based Authentication، وهو الأشيع اليوم. وفي الطريقة الثانية تنزل من حسابك في Apple Developer مفتاحاً بامتداد .p8 ومعه معرف المفتاح Key ID، وسيرفرك يوقع به توكن JWT بخوارزمية ES256، وهي الوحيدة التي تقبلها APNs، ويرسله في الترويسة authorization: bearer …. وتطلب Apple في صفحة الاتصال بالتوكن أن تجدد التوكن «مرة كل 20 دقيقة على الأكثر، ومرة كل 60 دقيقة على الأقل»، وترفض أي توكن عمره أكثر من ساعة. وقد شرحنا JWT والتوقيع ES256 في الجلسات وJWT.
ومن التفاصيل التي تهمك عند التصميم:
- حجم الرسالة Payload: 4 KB أي 4096 بايت لكل الإشعارات، و5 KB لإشعارات المكالمات VoIP، كما في صفحة إنشاء الإشعار، فالإشعار تنبيه قصير وليس وسيلة لنقل البيانات.
- الجهاز المطفأ: تحفظ APNs الإشعار حتى 30 يوماً بحسب
apns-expiration، ولكنها تحفظ إشعاراً واحداً فقط لكل تطبيق، فإذا أرسلت عشرة والهاتف مطفأ وصل غالباً آخرها فقط. وتذكر Apple أن الخدمة تعمل بأفضل جهد Best-Effort، فقد يتغير ترتيب الإشعارات أو تؤخر أو لا تصل بحسب حالة البطارية واستخدام الشخص للتطبيق. - التوكن يتغير: تطلب Apple في تسجيل التطبيق ألا يحفظ التطبيق التوكن محلياً، لأن APNs تصدر توكناً جديداً عند استعادة الجهاز من نسخة احتياطية وعند تثبيت التطبيق على جهاز جديد وعند إعادة تثبيت النظام، فاطلبه في كل تشغيل وأرسله إلى سيرفرك.
وتستخدم أنظمة إدارة الأجهزة MDM الخدمة نفسها لإيقاظ الأجهزة، كما شرحنا في إدارة أجهزة الموظفين.
خدمة Google للإشعارات FCM
خدمة Firebase Cloud Messaging واختصاراً FCM هي الطريق الرسمي إلى تطبيقات Android، ولكن لاحظ أنها أكثر من خدمة لنظام واحد. فبحسب صفحة بنية FCM يستقبل سيرفر FCM طلبك ثم يسلمه إلى طبقة نقل خاصة بالمنصة: طبقة النقل الخاصة بأجهزة Android التي عليها Google Play services، وAPNs لأجهزة Apple، وبروتوكول Web Push لتطبيقات الويب. أي أن تطبيقك على iPhone إذا استخدم FCM فإن رسالته تمر على Google ثم على Apple.
وعلى Android نفسه لا يفتح تطبيقك اتصالاً، وإنما تحافظ Google Play services على اتصال دائم مع Google، والتوثيق يطلب في إعدادات الشبكة أن تسمح الشبكة بالمنافذ 5228 و5229 و5230 و443، وأن تبقي شبكات NAT وجدران الحماية الاتصالات على المنافذ 5228 إلى 5230 مفتوحة ثلاثين دقيقة أو أكثر. وهذا يفسر مشكلة تتكرر في الشركات: هواتف الموظفين على شبكة الشركة لا تصلها الإشعارات إلا متأخرة، والسبب جدار حماية يغلق هذه المنافذ أو يقطع الاتصالات الهادئة بعد دقائق.
ويرسل سيرفرك إلى FCM عبر الواجهة HTTP v1 كما في صفحة الإرسال عبر HTTP v1: طلب POST إلى https://fcm.googleapis.com/v1/projects/<project-id>/messages:send يحمل توكن وصول OAuth 2.0 مصدره حساب خدمة Service Account من مشروع Firebase. ومن التفاصيل التي تهمك:
- نوعان من الرسائل: رسالة إشعار Notification Message يعرضها النظام وحده إذا كان التطبيق في الخلفية، ورسالة بيانات Data Message يستقبلها كود تطبيقك ويقرر ماذا يفعل بها، والحد الأقصى لكليهما 4096 بايت بحسب صفحة أنواع الرسائل.
- مدة الحفظ TTL: إذا كان الجهاز غير متصل تحفظ FCM الرسالة أربعة أسابيع افتراضياً، والحد الأقصى 2,419,200 ثانية أي 28 يوماً، كما في صفحة مدة الرسالة، ومفتاح الدمج Collapse Key يجعل الرسالة الجديدة تحل محل القديمة بدل أن تصل كلها.
- تنظيف التوكنات: تعتبر FCM توكن Android منتهياً إذا لم يستخدم 270 يوماً، وترد ب
UNREGISTEREDمع الرمز 404 على التوكن غير الصالح، وتنصح في إدارة التوكنات أن يحدث التطبيق توكنه مرة في الشهر.
المعيار المفتوح Web Push وVAPID
APNs وFCM خدمتان تملكهما شركتان، أما إشعارات المتصفح فلها معيار مفتوح من ثلاث وثائق في IETF، وهذا ما يجعلها أقرب الطرق إلى الاستضافة الذاتية من جهة سيرفرك:
- RFC 8030 يعرف بروتوكول خدمة الإشعارات Push Service: كيف يشترك المتصفح، وكيف يرسل سيرفر التطبيق Application Server إلى عنوان الاشتراك، ومعنى الترويسات
TTLوUrgencyوTopic. - RFC 8291 يفرض أن تكون الرسالة مشفرة من سيرفرك إلى المتصفح، بحيث لا تقرؤها خدمة الإشعارات التي تنقلها.
- RFC 8292 يعرف VAPID، أي الطريقة التي يثبت بها سيرفرك هويته لخدمة الإشعارات بزوج مفاتيح خاص به، دون حساب عند Google أو Apple.
ومن جهة المتصفح تعمل ثلاث واجهات معاً كما في توثيق Push API في MDN: ال Service Worker، وهو ملف JavaScript يسجله موقعك ويوقظه المتصفح عند وصول رسالة حتى لو كانت الصفحة مغلقة، وواجهة Push API التي تنشئ الاشتراك، وواجهة Notifications API التي تعرض الإشعار. وكل ذلك لا يعمل إلا في السياق الآمن Secure Context، أي موقع على HTTPS أو على localhost، وهذا ما يسمح لنا بالتجربة على جهازك دون شهادة.
وقد يتساءل البعض: إذا كان المعيار مفتوحاً، فهل تختار أنت خدمة الإشعارات؟ والإجابة لا، فالمتصفح هو الذي يختارها، وعنوان الاشتراك Endpoint الذي يعطيه لموقعك يشير إلى خدمة الشركة التي صنعته: Chrome يعطي عنواناً على fcm.googleapis.com كما سوف ترى في التجربة، وFirefox يعطي عنواناً على خدمة Mozilla التي نشرت كودها مفتوحاً باسم autopush-rs، وSafari يعطي عنواناً على نطاقات push.apple.com. وقد أضافت Apple دعم Web Push إلى Safari 16 على macOS Ventura دون حاجة إلى الاشتراك في برنامج المطورين، ثم إلى iOS وiPadOS 16.4 بشرط أن يضيف المستخدم الموقع إلى الشاشة الرئيسية Home Screen وأن يطلب الموقع الإذن بعد ضغطة من المستخدم، وتذكر Apple أن Web Push على iOS يستخدم خدمة APNs نفسها التي تستخدمها التطبيقات. وفي iOS 18.4 أضافت Declarative Web Push الذي يعرض الإشعار من محتوى الرسالة نفسها دون الحاجة إلى Service Worker.
والصورة التالية تبين ما يحدث بين الأطراف الثلاثة:

في الصورة أعلاه لاحظ التالي:
- الاشتراك Subscription: عند الاشتراك ينشئ المتصفح زوج مفاتيح ECDH على المنحنى P-256 وسراً عشوائياً اسمه
auth، ويعطي موقعك المفتاح العامp256dhوالسر مع ال Endpoint، ويبقي المفتاح الخاص عنده. - التشفير Encryption: سيرفرك ينشئ لكل رسالة زوج مفاتيح مؤقتاً وملحاً Salt عشوائياً، ويشتق منهما ومن مفتاح المتصفح مفتاح AES-GCM، ويرسل الرسالة بترميز
aes128gcm، وقد شرحنا تبادل المفاتيح والتشفير الموثق في التشفير للمطورين. - التوقيع VAPID: سيرفرك يوقع توكن JWT بمفتاحه الخاص فيه ثلاثة حقول:
audوهو أصل عنوان خدمة الإشعارات، وexpوقت الانتهاء الذي يمنع المعيار أن يزيد على 24 ساعة، وsubوهو بريد أو رابط تتواصل به معك الخدمة إذا حدثت مشكلة. وعند الاشتراك يعطي موقعك المتصفح مفتاحه العام، فتربط الخدمة الاشتراك بهذا المفتاح وترفض أي سيرفر آخر. - الحجم: يلزم RFC 8030 خدمة الإشعارات أن تقبل 4096 بايت على الأقل، وبعد ترويسة التشفير والحشو ووسم التحقق يبقى للنص نفسه 3993 بايت كحد أقصى بحسب RFC 8291.
الخدمات الثلاث في مكان واحد
الصورة التالية تجمع ما سبق في ثلاث بطاقات، وبعدها جدول بالأرقام نفسها:

| APNs | FCM | Web Push | |
|---|---|---|---|
| من يملكها | Apple | معيار IETF، والخدمة يختارها المتصفح | |
| من يحفظ الاتصال | النظام | Google Play services | المتصفح |
| عنوان التطبيق | Device Token | Registration Token | Endpoint ومفتاحان |
| هوية سيرفرك | مفتاح p8 وJWT، أو شهادة | Service Account وOAuth 2.0 | مفتاح VAPID وJWT |
| حد الرسالة | 4096 بايت | 4096 بايت | 4096 بايت مشفرة على الأقل |
| هل تقرأ الخدمة المحتوى؟ | نعم إلا إذا شفرته بنفسك | نعم إلا إذا شفرته بنفسك | لا، التشفير إلزامي |
ما الذي يمر عبر سيرفرات Google وApple؟
كل إشعار يمر على طرف ثالث، وهذا ليس خياراً تملكه في iOS ولا في Android العادي ولا في المتصفحات. والسؤال الصحيح إذن ليس «هل يمر؟» وإنما «ماذا يرى هذا الطرف؟»، والإجابة على قسمين، والصورة التالية تبينهما:

- البيانات الوصفية Metadata تمر دائماً: أي تطبيق، وأي توكن، ومرتبط بأي حساب وأي جهاز، ومتى وصل الإشعار، وحجمه، ومن أي سيرفر جاء. وهذه وحدها تكشف كثيراً، فمعرفة أن هاتفاً معيناً يستقبل إشعارات من تطبيق علاج أو تطبيق محادثة معين في أوقات معينة معلومة حساسة دون قراءة حرف من المحتوى.
- المحتوى يمر إذا وضعته فيه: تقول Google في صفحة التشفير في FCM إن الاتصال مشفر ب TLS من نقطة إلى نقطة، وإن هذا «ليس مشفراً على كامل الطريق بحيث لا يفك الرسالة إلا المرسل والمستقبل»، وإن FCM «لا يقدم حلاً مدمجاً للتشفير من طرف إلى طرف» وينصح به للمحادثات وعمليات التوثيق. أما Web Push فالتشفير فيه إلزامي كما رأينا.
وفيما يلي ما تستطيع أن تفعله أنت:
- لا تضع في الإشعار ما لا تريد أن يراه طرف ثالث: رموز الدخول OTP، والمبالغ، والتشخيصات، ونص المحادثات. والسبب أن هذا النص يمر على سيرفرات الشركة ويظهر أيضاً على شاشة الهاتف المقفل.
- أرسل إشعاراً عاماً واجلب التفاصيل: «لديك رسالة جديدة» فقط، ثم يجلب التطبيق المحتوى من سيرفرك عبر HTTPS بعد أن يستيقظ، وهذا بالضبط ما يفعله تطبيق ntfy على iOS مع السيرفرات المستضافة ذاتياً كما سوف نرى في مقال ntfy.
- أو شفر المحتوى بنفسك: على iOS يستطيع امتداد Notification Service Extension أن يعدل الإشعار قبل عرضه، ومن الأمثلة التي تذكرها Apple في تعديل محتوى الإشعار فك بيانات أرسلت مشفرة، وعلى Android تستقبل رسالة البيانات Data Message في كودك وتفكها قبل العرض.
- احم جدول التوكنات: خزنه كبيانات شخصية، واحذف التوكن عندما ترد الخدمة بأنه غير صالح، ولا تكتبه كاملاً في السجلات، كما شرحنا في ما لا يدخل السجل أبداً.
ما الذي تستطيع استضافته بنفسك فعلاً؟
هنا تحتاج إلى الصراحة، لأن كثيراً من الأدوات تكتب «إشعارات مستضافة ذاتياً» والمقصود جزء من الطريق فقط:
- iOS: لا يوجد طريق إلى تطبيق مغلق إلا APNs، فأي أداة تستضيفها سوف تنتهي بطلب إلى Apple، والذي تملكه هو ما يحمله الإشعار، فتجعله عاماً أو مشفراً.
- Android: تستطيع أن تتجاوز FCM إذا كان التطبيق نفسه يحفظ اتصالاً دائماً بسيرفرك عبر خدمة تعمل في المقدمة Foreground Service يراها المستخدم كإشعار دائم، وهذا ما يفعله تطبيق ntfy في نسخة F-Droid، وما يبنيه مشروع UnifiedPush لكل التطبيقات، وله مقال مخطط في المرحلة نفسها.
- المتصفح: لا تختار خدمة الإشعارات، ولكن المحتوى مشفر إلزامياً، وكل ما عدا ذلك تستضيفه أنت: سيرفر التطبيق، والمفاتيح، وجدول الاشتراكات، دون حساب عند أي شركة، وهذا ما سوف نبنيه الآن.
- تنبيهات فريقك وسيرفراتك: إذا كان المستقبل أنت وفريقك وليس عملاءك، فأداة مثل ntfy على سيرفرك مع تطبيقها على الهاتف تكفي، وتصل إلى Android دون Google، وهي موضوع المقال التالي.
تجربة عملية: إشعار Web Push من سيرفر على جهازك
الآن سوف نبني الأطراف كلها عدا خدمة الإشعارات: سيرفر تطبيق صغير ب Node.js داخل Container يقدم صفحة وملف Service Worker ويحفظ الاشتراكات ويرسل الإشعارات المشفرة بمكتبة web-push وترخيصها MPL-2.0، ثم نفتح الصفحة في Chrome ونشترك ونرسل ونرى الرسالة تصل، ثم نرى ما تستلمه خدمة Google بالبايت، ونكسر التجربة ثلاث مرات. والتجربة تحتاج Docker ومتصفح Chrome أو Firefox على جهازك، وتعمل على العنوان 127.0.0.1 فلا يصل إليها أحد من خارج جهازك، ولكن الإشعار نفسه يخرج إلى الإنترنت ويعود، لأن هذه هي الفكرة.
ملفات التجربة
أنشئ مجلداً للتجربة وفيه الملفات التالية، والأول ملف المكتبات:
mkdir -p push-lab/public && cd push-lab{ "name": "push-lab", "private": true, "type": "module", "dependencies": { "web-push": "3.6.7" } }والملف التالي server.mjs هو سيرفر التطبيق Application Server:
// Application server for the Web Push lab: serves the page and the service worker,
// stores subscriptions in memory, and sends encrypted pushes with VAPID.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import webpush from 'web-push';
const { VAPID_PUBLIC, VAPID_PRIVATE } = process.env;
webpush.setVapidDetails('mailto:[email protected]', VAPID_PUBLIC, VAPID_PRIVATE);
const subscriptions = [];
const body = (req) => new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)); });
const files = { '/': ['public/index.html', 'text/html'], '/sw.js': ['public/sw.js', 'text/javascript'] };
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
if (files[url.pathname]) {
const [f, type] = files[url.pathname];
res.writeHead(200, { 'Content-Type': type });
return res.end(await readFile(f));
}
if (url.pathname === '/vapid-public-key') return res.end(VAPID_PUBLIC);
if (req.method === 'POST' && url.pathname === '/subscribe') {
const sub = JSON.parse(await body(req));
subscriptions.push(sub);
console.log('new subscription:', JSON.stringify(sub, null, 2));
return res.end('ok');
}
if (req.method === 'POST' && url.pathname === '/ack') {
console.log('service worker received:', await body(req));
return res.end('ok');
}
if (req.method === 'POST' && url.pathname === '/send') {
const payload = JSON.stringify({ title: 'سيرفر الإنتاج', body: url.searchParams.get('msg') || 'اختبار' });
const results = [];
for (const sub of subscriptions) {
try {
const r = await webpush.sendNotification(sub, payload, { TTL: 60, urgency: 'high' });
results.push({ status: r.statusCode, location: r.headers.location });
} catch (e) {
results.push({ status: e.statusCode, error: e.body?.trim() });
}
}
console.log('send results:', JSON.stringify(results));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(results));
}
res.writeHead(404); res.end();
}).listen(3000, () => console.log('listening on :3000'));والصفحة public/index.html فيها زر الاشتراك:
<!doctype html>
<html lang="ar" dir="rtl">
<meta charset="utf-8">
<title>Web Push lab</title>
<button id="sub">اشترك في التنبيهات</button>
<pre id="out"></pre>
<script>
const out = (t) => document.getElementById('out').textContent += t + '\n';
const b64 = (s) => Uint8Array.from(atob((s + '='.repeat((4 - s.length % 4) % 4)).replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
document.getElementById('sub').onclick = async () => {
const reg = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
out('permission: ' + permission);
const key = await (await fetch('/vapid-public-key')).text();
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64(key) });
await fetch('/subscribe', { method: 'POST', body: JSON.stringify(sub) });
out('endpoint: ' + sub.endpoint);
};
</script>وملف ال Service Worker public/sw.js الذي يوقظه المتصفح عند وصول الرسالة، ويعرض الإشعار ويبلغ السيرفر أنه استلمه حتى نرى ذلك في السجل:
// Runs in the background even when the page is closed; the browser wakes it for each push.
self.addEventListener('push', (event) => {
const data = event.data.json(); // already decrypted by the browser
event.waitUntil(Promise.all([
self.registration.showNotification(data.title, { body: data.body, lang: 'ar', dir: 'rtl' }),
fetch('/ack', { method: 'POST', body: JSON.stringify(data) }),
]));
});في الملفات أعلاه لاحظ التالي:
- الخيار
userVisibleOnly: trueإلزامي في Chrome وEdge بحسب توثيق MDN وفي Safari بحسب Apple، فالمتصفح يعطيك حق إيقاظ ال Service Worker بشرط أن تعرض إشعاراً يراه المستخدم في كل مرة، ولا يسمح بإشعارات صامتة تشغل كوداً في الخلفية. - الدالة
event.data.json()تعطيك النص بعد فك التشفير، فالمتصفح هو الذي يفك ولا يكتب كودك شيئاً من ذلك. - السيرفر يرسل مع كل رسالة
TTL: 60، أي أن خدمة الإشعارات تحذفها إذا لم تستطع توصيلها خلال دقيقة، وurgency: 'high'لأن تنبيه القرص الممتلئ لا ينتظر. - الاشتراكات في الذاكرة فقط لأنها تجربة، وفي تطبيق حقيقي تحفظها في قاعدة البيانات مع رقم المستخدم.
مفاتيح VAPID وتشغيل السيرفر
نثبت المكتبة وننشئ زوج مفاتيح VAPID بالأداة التي تأتي معها:
docker run --rm -v "$PWD:/app" -w /app node:22-alpine \
sh -c "npm install --no-audit --no-fund && npx web-push generate-vapid-keys --json"والمخرج سوف يكون كما يلي (اختصرنا المفتاح الخاص هنا، ولا تنشر مفتاحك الخاص أبداً):
{"publicKey":"BN_7oOPVmwxHzM_Ftqu12_kWQ7OZh8QSFc057xf4b6tnhsxnUOF0CqoD7RkgeE-jdMmy6R0Ficg7AgmnSO9LKZQ","privateKey":"UREvq7ts…"}المفتاح العام يذهب إلى المتصفح عند الاشتراك، والخاص يبقى في سيرفرك يوقع به كل رسالة، وإذا ضاع الخاص أو تغير فكل الاشتراكات القديمة تصبح بلا فائدة كما سوف ترى. الآن نشغل السيرفر:
export VAPID_PUBLIC='BN_7oOPVmwxHzM_Ftqu12_kWQ7OZh8QSFc057xf4b6tnhsxnUOF0CqoD7RkgeE-jdMmy6R0Ficg7AgmnSO9LKZQ'
export VAPID_PRIVATE='…'
docker run -d --name lab-notify-push -p 127.0.0.1:18471:3000 \
-v "$PWD:/app" -w /app -e VAPID_PUBLIC -e VAPID_PRIVATE \
node:22-alpine node server.mjs
docker logs lab-notify-pushlistening on :3000الاشتراك من المتصفح ثم الإرسال
افتح http://127.0.0.1:18471 في Chrome واضغط «اشترك في التنبيهات» ووافق على نافذة الإذن. وفي تجربتنا شغلنا Chrome 154 من سكربت عبر DevTools وأعطيناه الإذن تلقائياً، والنتيجة نفسها عندما تضغط بنفسك، وظهر في الصفحة:
permission: granted
endpoint: https://fcm.googleapis.com/fcm/send/d3UMFGul58E:APA91bE543ycvsATkYinnMwrHJjKNOwQUhqtvRH6H94DHA0tOVkyXLpsqpoOf2MSN9mjEDL-dXnKUADDr6u6J0E-b71azKUHpJCWazdqJHPA7eh9cNZt6GgpqDCtFpAWmLKpgWHs-x53لاحظ أن ال Endpoint على fcm.googleapis.com، فالاشتراك من Chrome يمر عبر Google حتى لو لم يكن لك أي حساب فيها. والآن نرسل إشعاراً:
curl -s -X POST --url-query "msg=القرص ممتلئ 91%" http://127.0.0.1:18471/send[{"status":201,"location":"https://fcm.googleapis.com/0:1791386491080853%e609af1cf9fd7ecd"}]وظهر الإشعار على الشاشة، وسجل السيرفر يبين ما حدث من الاشتراك إلى الاستلام:
docker logs lab-notify-pushlistening on :3000
new subscription: {
"endpoint": "https://fcm.googleapis.com/fcm/send/d3UMFGul58E:APA91bE543ycvsATkYinnMwrHJjKNOwQUhqtvRH6H94DHA0tOVkyXLpsqpoOf2MSN9mjEDL-dXnKUADDr6u6J0E-b71azKUHpJCWazdqJHPA7eh9cNZt6GgpqDCtFpAWmLKpgWHs-x53",
"expirationTime": null,
"keys": {
"p256dh": "BKyUiKKlBaxejS0I2qnv_ZDRcanlxtJtgxlpX5lrmOWxaOqR3-vpZh4E9dy0J-Cqmuv_c7adkvvm1013tQIdZPk",
"auth": "H7xvAHMg2f9xxSSiUQmnJw"
}
}
send results: [{"status":201,"location":"https://fcm.googleapis.com/0:1791386491080853%e609af1cf9fd7ecd"}]
service worker received: {"title":"سيرفر الإنتاج","body":"القرص ممتلئ 91%"}في المخرج أعلاه لاحظ التالي:
- الاشتراك هو ثلاث قيم: ال Endpoint ومفتاح المتصفح العام
p256dhوالسرauth، وهذه هي كل ما يحتاجه سيرفرك ليرسل إلى هذا المتصفح، لذلك عاملها كبيانات حساسة. - الرمز 201 يعني أن خدمة Google قبلت الرسالة، وليس أنها وصلت، وسوف ترى الفرق بعد قليل.
- السطر الأخير كتبه ال Service Worker بعد أن فك التشفير، فالنص العربي وصل كما هو.
ماذا استلمت خدمة Google بالضبط؟
نسخنا الاشتراك من السجل إلى ملف subscription.json، ثم كتبنا سكربتاً يبني الطلب نفسه الذي ترسله المكتبة دون أن يرسله، ويطبع ترويساته وجسمه:
// What the push service receives: headers, the VAPID token, and an encrypted body.
import webpush from 'web-push';
import { readFileSync } from 'node:fs';
const sub = JSON.parse(readFileSync('subscription.json', 'utf8'));
const payload = JSON.stringify({ title: 'سيرفر الإنتاج', body: 'القرص ممتلئ 91%' });
const req = webpush.generateRequestDetails(sub, payload, {
vapidDetails: { subject: 'mailto:[email protected]', publicKey: process.env.VAPID_PUBLIC, privateKey: process.env.VAPID_PRIVATE },
TTL: 60, urgency: 'high',
});
console.log(req.method, req.endpoint.slice(0, 60) + '…');
for (const [k, v] of Object.entries(req.headers)) console.log(`${k}: ${String(v).slice(0, 90)}${String(v).length > 90 ? '…' : ''}`);
const jwt = req.headers.Authorization.match(/t=([^,]+)/)[1];
const [h, p] = jwt.split('.').slice(0, 2).map(s => JSON.parse(Buffer.from(s, 'base64url')));
console.log('\nVAPID JWT header :', JSON.stringify(h));
console.log('VAPID JWT claims :', JSON.stringify(p));
console.log('exp - now (hours):', ((p.exp - Date.now() / 1000) / 3600).toFixed(1));
console.log('\nplaintext bytes :', Buffer.byteLength(payload));
console.log('encrypted bytes :', req.body.length);
console.log('first 48 bytes :', req.body.subarray(0, 48).toString('hex'));docker exec -w /app lab-notify-push node inspect.mjsPOST https://fcm.googleapis.com/fcm/send/d3UMFGul58E:APA91bE543yc…
TTL: 60
Content-Length: 175
Content-Type: application/octet-stream
Content-Encoding: aes128gcm
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvb…
Urgency: high
VAPID JWT header : {"typ":"JWT","alg":"ES256"}
VAPID JWT claims : {"aud":"https://fcm.googleapis.com","exp":1791429865,"sub":"mailto:[email protected]"}
exp - now (hours): 12.0
plaintext bytes : 72
encrypted bytes : 175
first 48 bytes : 6003c8a462ee6ba041343fb7b769867c000010004104e53cd2f0db51ae31249c2994cc6e298521084f417da9650e53efفي المخرج أعلاه لاحظ التالي:
- ترويسة
Authorization: vapid t=…تحمل توكن JWT موقعاً ب ES256، والحقلaudهو أصل خدمة Google فقط، فالتوكن نفسه يصلح لكل الاشتراكات علىfcm.googleapis.com، والمكتبة جعلت صلاحيته 12 ساعة، أقل من حد 24 ساعة الذي يفرضه RFC 8292. - النص الأصلي 72 بايت وأصبح 175، والفرق 103 بايت هي بالضبط ترويسة التشفير (86 بايت) مع بايت حشو واحد ووسم التحقق في AES-GCM وطوله 16 بايت.
- أول 16 بايت هي الملح Salt، ثم
00001000وهو حجم السجل 4096، ثم41أي 65 بايت للمفتاح المؤقت، ثم04…وهو بداية المفتاح العام المؤقت الذي أنشأه سيرفرك لهذه الرسالة، وبعدها النص المشفر. وهذا كل ما تراه Google من المحتوى.
وعندما شغلنا السكربت مرة قبلها كانت أول البايتات 7cf94403b9678b2dca7e4682ad78fec4…، أي أن الرسالة نفسها تشفر في كل مرة ببايتات مختلفة لأن الملح والمفتاح المؤقت جديدان، فلا تستطيع الخدمة أن تعرف أن رسالتين متطابقتان.
نكسر التجربة: مفتاح آخر، وصفحة مغلقة، واشتراك ملغى
أولاً نرسل إلى الاشتراك نفسه ولكن بزوج مفاتيح VAPID جديد، وهو ما يحدث عندما تنقل السيرفر وتنسى ملف المفاتيح، أو يكون عندك سيرفران بمفتاحين مختلفين:
// Send to the same subscription, but sign with a different VAPID key pair.
import webpush from 'web-push';
import { readFileSync } from 'node:fs';
const sub = JSON.parse(readFileSync('subscription.json', 'utf8'));
const other = webpush.generateVAPIDKeys();
try {
await webpush.sendNotification(sub, 'test', {
vapidDetails: { subject: 'mailto:[email protected]', ...other }, TTL: 60,
});
} catch (e) {
console.log(e.statusCode, e.body.trim());
}docker exec -w /app lab-notify-push node wrong-key.mjs403 the VAPID credentials in the authorization header do not correspond to the credentials used to create the subscriptions.ترفض Google الرسالة لأن الاشتراك مربوط بالمفتاح العام الذي أعطيته للمتصفح عند الاشتراك، والحل الوحيد هو المفتاح الأصلي أو أن يشترك كل مستخدم من جديد، لذلك احفظ مفاتيح VAPID مع أسرار الإنتاج في نسختك الاحتياطية.
ثانياً أعدنا تشغيل السيرفر واشتركنا من جديد، ثم أغلقنا الصفحة وتركنا Chrome يعمل، وأرسلنا:
curl -s -X POST --url-query "msg=النسخ الاحتياطي فشل" http://127.0.0.1:18471/send
docker logs lab-notify-push | tail -n 2send results: [{"status":201,"location":"https://fcm.googleapis.com/0:1791386834111662%e609af1cf9fd7ecd"}]
service worker received: {"title":"سيرفر الإنتاج","body":"النسخ الاحتياطي فشل"}وصل الإشعار والصفحة مغلقة، لأن المتصفح هو الذي يحفظ الاتصال ويوقظ ال Service Worker، وهذا هو الفرق بين Web Push وأي اتصال WebSocket تفتحه الصفحة بنفسها.
ثالثاً نلغي الاشتراك من المتصفح، ويكفي أن تكتب في ال Console في أدوات المطور:
await navigator.serviceWorker.ready.then(r => r.pushManager.getSubscription()).then(s => s.unsubscribe())truecurl -s -X POST --url-query "msg=هل يصل؟" http://127.0.0.1:18471/send[{"status":410,"error":"push subscription has unsubscribed or expired."}]الرمز 410 Gone يعني أن هذا الاشتراك انتهى إلى الأبد، والتصرف الصحيح أن يحذفه سيرفرك من قاعدة البيانات فوراً، وإلا تراكمت آلاف الاشتراكات الميتة ترسل إليها مع كل حدث. وانتبه للحالة المعاكسة التي رأيناها في جولة سابقة، حين حذفنا ملف تعريف Chrome بالكامل ثم أرسلنا إلى اشتراكه القديم فكان الرد 201، لأن الخدمة لا تعرف أن المتصفح لن يعود وتحفظ الرسالة حتى تنتهي مدة TTL، فالرمز 201 قبول وليس توصيلاً.
إزالة التجربة
docker rm -f lab-notify-push
docker rmi node:22-alpine # إذا لم تكن تحتاجه لشيء آخر
cd .. && rm -rf push-labوألغ الإذن من إعدادات الموقع في Chrome إذا ضغطت الزر بنفسك.
أخطاء شائعة
- طلب الإذن عند فتح الصفحة: نافذة إذن تظهر قبل أن يفهم الزائر ماذا سيستقبل تضغط عليها «رفض» في الغالب، وSafari على iOS لا يسمح أصلاً إلا بعد ضغطة من المستخدم، فاطلب الإذن من زر يشرح الفائدة.
- اعتبار 201 توصيلاً: القبول عند خدمة الإشعارات لا يعني أن المستخدم رأى شيئاً، فإذا كانت الرسالة مهمة مثل تنبيه أمني فاجعل لها قناة ثانية كالبريد.
- ترك التوكنات الميتة: احذف التوكن عند 404 أو 410 أو
UNREGISTERED، وحدثه كلما أرسله التطبيق. - البيانات الحساسة في نص الإشعار: رمز OTP في إشعار يقرؤه كل من يرى الشاشة المقفلة، ويمر على سيرفرات الشركة.
- ضياع مفاتيح VAPID أو مفتاح p8: الأول يلغي كل اشتراكات المتصفحات، والثاني يوقف كل إشعارات iOS حتى ترفع مفتاحاً جديداً، فاحفظهما مع أسرار الإنتاج.
- جدار حماية يقطع اتصالات الهواتف: شبكة الشركة التي تغلق المنافذ 5228 إلى 5230 أو تقطع الاتصالات الهادئة تؤخر إشعارات Android للموظفين دون أن يفهم أحد السبب.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الإشعارات الفورية اتصال واحد يحفظه النظام أو المتصفح مع خدمة يملكها المصنع، وكل التطبيقات تستقبل رسائلها عبره، لذلك لا يسأل التطبيق سيرفره ولا يحتاج إلى العمل في الخلفية.
- سيرفرك لا يعرف الهاتف، ويعرف فقط التوكن الذي يرسله التطبيق، فاحفظه مع المستخدم وحدثه واحذفه عندما ترده الخدمة.
- APNs طريق إلزامي إلى أجهزة Apple، وFCM هو الطريق على Android وهو نفسه يمر عبر APNs لتطبيقات iOS، وWeb Push معيار مفتوح بتشفير إلزامي ومفاتيح VAPID لا تحتاج حساباً عند أحد.
- البيانات الوصفية تمر دائماً على الطرف الثالث ويمكن أن تطلبها جهات قانونية، والمحتوى يمر فقط إذا وضعته فيه، فأرسل إشعاراً عاماً أو مشفراً واجلب التفاصيل من سيرفرك.
- ما تستضيفه بنفسك فعلاً هو سيرفر التطبيق والمفاتيح والاشتراكات، وعلى Android تستطيع أن تتجاوز Google بتطبيق يحفظ اتصاله بسيرفرك مثل ntfy، أما iOS فلا طريق فيه إلا APNs.
دليلإشعارات فورية من سيرفرك إلى هاتفك مع ntfyكيف تشغل سيرفر ntfy خاصاً، وتصلك على هاتفك رسالة عندما يمتلئ القرص أو تفشل النسخة الاحتياطية أو ينتهي النشر.
دليلما هو NAT؟ شرح ترجمة العناوين وتوجيه المنافذلماذا تخرج كل أجهزة بيتك بعنوان عام واحد، وكيف يترجم ال Router العناوين، ولماذا يفشل توجيه المنافذ خلف CGNAT.
دليلالتشفير للمطورين: ماذا تستخدم ولماذا لا تخترع خوارزميتكأي هاش تستخدم لكلمات المرور، ومتى تحتاج التشفير المتماثل أو غير المتماثل، وأين تحفظ المفاتيح بأمان في تطبيقك.
دليلكيف يتذكرك الموقع بعد تسجيل الدخول؟ الجلسات وJWTبروتوكول HTTP لا يتذكر أحداً، فكيف يبقى المستخدم مسجلاً بين الصفحات، ومتى تختار الجلسة على السيرفر ومتى تختار JWT.
دليلMDM: إدارة أجهزة الموظفين وتأمينها بأدوات مفتوحةكيف تدير الشركة حواسيب موظفيها وهواتفهم وتفرض عليها التشفير والتحديث، وما الذي تستطيع أن تراه فعلاً على كل جهاز.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة Web Push بمكتبة web-push 3.6.7 على Node.js 22.23.3 داخل Docker مع Chrome 154، والإرسال الحقيقي عبر خدمة FCM.