نشرنا إصلاحاً لخطأ في حاسبة أسبوع الحمل داخل تطبيق ويب تقدّمي (PWA) نشتغل عليه، وتأكّدنا بأنفسنا أنه يعمل: فتحنا الموقع في نافذة تصفّح خاصة فظهر التعديل فوراً. ثم وصلت رسالة من مستخدمة تقول إن الرقم القديم ما زال كما هو. طلبنا منها تحديث الصفحة — لا شيء. أغلقت التطبيق المثبّت على شاشتها الرئيسية وأعادت فتحه — لا شيء. استغرق تشخيص هذه الحالة وقتاً أطول بكثير مما تتوقّع، لأن كل المؤشّرات كانت تقول إن النشر نجح: الملف الجديد موجود على الخادم، ورقم الإصدار تغيّر، والصفحة تُحمَّل بلا أخطاء في الطرفية (console). المشكلة أن الخلل لم يكن في النشر أصلاً، بل في طبقتَي كاش تفصلان بيننا وبين جهاز المستخدمة: رؤوس HTTP التي طلبنا فيها بأنفسنا من المتصفح أن يحتفظ بملفات JS و CSS سنةً كاملة، و Service Worker مثبَّت على جهازها يخدم بأمانة النسخة التي خزّنها هو. هذا المقال يشرح ما فعلناه فعلياً لحلّ ذلك في موقع إنتاجي: نقطة تعديل واحدة لكل إصدار، وثلاث نقاط يجب أن تُزامَن معها، ودورة حياة تفسّر لماذا يبقى الكود القديم يعمل حتى بعد أن يصل الجديد بنجاح.
1. أين تختبئ العلّة فعلياً
ملف firebase.json في المشروع يحدّد رؤوس الكاش لكل نوع ملف، وهذا سطره الأهم للأصول:
"source": "**/*.@(js|css)"
"Cache-Control": "public, max-age=31536000, immutable"
الرقم 31536000 ثانية = سنة كاملة، وكلمة immutable تعني: لا تسأل الخادم عن هذا الملف مرة أخرى، ولا حتى بطلب تحقّق (revalidation). هذا الرأس ليس خطأً — هو مطلوب للسرعة، وبفضله يفتح الموقع فوراً في الزيارات التالية. لكنه يعني أيضاً أن الملف الذي حمّله جهاز المستخدمة أمس لن يُطلب أبداً ما بقي عنوانه هو نفسه.
الطبقة الثانية أخطر: الـ Service Worker. حين يكون مثبَّتاً، فهو من يجيب على طلبات الصفحة لا الشبكة. وإذا كانت استراتيجيته «الكاش أولاً»، فسيقدّم النسخة المخزّنة عنده حتى لو حذفت كاش المتصفح يدوياً. لذلك فشلت كل محاولات «حدّثي الصفحة»: التحديث يطلب الملف، والـ Service Worker يجيب من مخزنه، والدائرة مغلقة. الحلّ لا يكون بإقناع المتصفح بأن يتجاهل الكاش، بل بأن تغيّر أنت عناوين الملفات وهوية المخزن عند كل نشر.
immutable يجب أن يحمل معه ما يميّز إصداره في عنوانه (معامل ?v= أو بصمة في اسمه). الرأس الطويل بلا تمييز إصدار = تعديلات لا تصل، وقد تبقى كذلك شهوراً.
2. نقطة تعديل واحدة: ثابت VERSION
القرار الهندسي الأول كان تقليص «مصدر الحقيقة» إلى سطر واحد في أعلى sw.js:
const VERSION = '20260809d';
const CACHE = `wasan-${VERSION}`;
لاحظ أن اسم المخزن مبنيّ على الإصدار لا مكتوباً بيده. هذه تفصيلة صغيرة لكنها أساس الآلية كلها: تغيير VERSION يعني تلقائياً مخزناً جديداً باسم جديد، وبالتالي لا يمكن أن يخدم الإصدار الجديد بقايا الإصدار القديم بالخطأ. ولأن الحرف الأخير جزء من الاسم، اعتمدنا نمط تسمية YYYYMMDDx حيث x حرف صغير يبدأ من a ويتزايد مع كل نشر في اليوم نفسه — فالإصدار 20260809d يعني رابع نشر في ذلك اليوم. هذا يوفّر شيئاً عملياً: عند وصول شكوى، يكفي أن تسأل عن الإصدار الظاهر على جهاز المستخدمة لتعرف أي نشر تحديداً وصلها.
لكي يعمل هذا، يجب ألّا يُكاش ملف الـ Service Worker نفسه أبداً — وإلا صار هو المشكلة. لذلك له رأس خاص:
"source": "**/sw.js"
"Cache-Control": "no-cache, no-store, must-revalidate"
"Service-Worker-Allowed": "/"
ويُسجَّل من الصفحة بخيار صريح يمنع المتصفح من قراءة نسخة مخزّنة منه:
navigator.serviceWorker.register('./sw.js', { updateViaCache: 'none' })
بهذين الاثنين معاً — رأس على الخادم وخيار في التسجيل — يُفحص الملف عند كل فتح للموقع، بما في ذلك فتح التطبيق المثبَّت على الشاشة الرئيسية.
3. لماذا لا يكفي تغيير VERSION وحده
هذا هو الدرس الذي كلّفنا أكثر من نشر فاشل. تغيير VERSION يُثبّت Service Worker جديداً ويحذف المخازن القديمة، لكنه لا يفعل شيئاً تجاه سطر مثل هذا في index.html:
<script src="dist/js/app.min.js?v=20260809d"></script>
إذا نشرت كوداً جديداً وأبقيت الرقم القديم في هذا السطر، فالصفحة تطلب العنوان القديم نفسه، والمتصفح يجيب من قرصه بسبب immutable دون أن يسأل أحداً. الـ Service Worker الجديد يعمل بامتياز، ويخدم لك ملفاً قديماً بكل إخلاص. لذلك يجب أن تُزامَن ثلاث نقاط في كل نشر:
- ثابت
VERSIONفيsw.js— يُنشئ جيلاً جديداً من المخزن ويُطلق دورة التحديث. - معامل
?v=لكل أصل فيindex.html— يبطل كاش القرص للملفات التي تغيّرت فعلاً. - حقل
versionفيversion.json— النقطة التي تستعلم عنها الصفحة وهي تعمل لتعرف أن هناك جديداً.
في البداية كنّا نُحدّث كل معاملات ?v= بقيمة واحدة عبر بحث واستبدال شامل. بدا الأمر مريحاً، ثم اكتشفنا ثمنه: أي نشر — ولو لتعديل سطر واحد — يُبطل كاش كل ملفات المشروع، فتُعاد تنزيل حزم لم تتغيّر على اتصال جوّال. فانتقلنا إلى وسم كل أصل بإصداره الخاص، فأصبح الملف الرئيسي يحمل ?v=20260809d بينما تبقى ملفات لم تُلمس منذ أسابيع على إصداراتها القديمة. النتيجة: نشر يكلّف المستخدمة تنزيل ما تغيّر فقط.
4. دورة الحياة: install ثم waiting ثم activate
فهم هذه الثلاث يفسّر معظم ما يبدو «سلوكاً غريباً». عند تغيّر ملف الـ Service Worker، يُنزّله المتصفح ويشغّل حدث install. هنا نفتح المخزن ونضع فيه الأصول الثابتة فقط:
const ASSETS = ['./', './index.html', './assets/images/wasanlogo.webp', './manifest.json'];
ولاحظ ما ليس في القائمة: ملفات JS و CSS. استثنيناها عن قصد، لأن عناوينها تحمل ?v= متغيّراً، فإدراجها هنا يعني تكرار الرقم في مكان رابع لا داعي له — وستُخزَّن تلقائياً عند أول طلب لها.
بعد نجاح install، لا يتولّى النسخة الجديدة التحكّم مباشرةً. تدخل حالة waiting: مثبَّتة، جاهزة، لكنها واقفة على الباب. السبب أن الـ Service Worker القديم لا يزال «متحكّماً» بكل التبويبات المفتوحة، والمتصفح يرفض تبديله تحتها لأن ذلك قد يخلط كوداً قديماً في الصفحة مع كود جديد يخدمها. افتراضياً لا يُسلَّم التحكّم إلا بعد إغلاق كل تبويبات الموقع — وهذا سبب الشكوى الأصلية: مستخدمة تُبقي التطبيق مفتوحاً في الخلفية أسبوعاً كاملاً لا يصلها شيء، وقد لا يُغلق التطبيق المثبَّت فعلياً عند تصغيره.
هناك مخرجان من الانتظار. الأول أن تطلب الصفحة التبديل برسالة:
waitingSW.postMessage({ type: 'SKIP_WAITING' });
والثاني — وهو ما استقرّينا عليه — أن يتجاوز الـ Service Worker الانتظار من داخله لحظة تثبيته: self.skipWaiting() داخل معالج install. أبقينا قناة SKIP_WAITING موجودة رغم ذلك، لأنها تخدم النسخ التي دخلت waiting في زيارة سابقة قبل أن يصل هذا التعديل.
ثم يأتي activate، وفيه ثلاث مهام بالترتيب: حذف كل مخزن اسمه لا يطابق المخزن الحالي (keys.filter(k => k !== CACHE))، ثم self.clients.claim() ليتولّى التحكّم بالتبويبات المفتوحة حالاً، ثم إبلاغها:
const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
wins.forEach(c => c.postMessage({ type: 'SW_ACTIVATED', version: VERSION }));
الخيار includeUncontrolled: true ليس زينة: بدونه تُستثنى التبويبات التي لم يتولّ التحكّم بها بعد — وهي أحياناً التبويب الذي يشكو صاحبه.
كيف نسأل الـ Service Worker عن إصداره
أضفنا قناة استعلام بسيطة (GET_VERSION) تجيب عبر MessageChannel، ولفّينا الاستدعاء في دالة بمهلة قصيرة (نحو 1200 مللي ثانية) حتى لا تتعلّق الصفحة إن لم يجب أحد. فائدتها العملية أن الصفحة تقارن إصدار النسخة النشطة بإصدار النسخة المنتظرة، وإذا تساويا فلا تُطلق تحديثاً أصلاً. هذه المقارنة أنهت حالة مزعجة كانت تُعيد تحميل الصفحة بلا سبب لأن المتصفح أبلغ عن نسخة «جديدة» تحمل الإصدار نفسه.
5. من يملك التحكّم؟ التحديث الصامت وحلقة الـ reload
بنينا أولاً الطريقة «المهذّبة»: شريط في أسفل الشاشة يقول «تحديث متوفّر — تطبيق»، ومن لا ينقره يُطبَّق التحديث تلقائياً بمجرّد تغييب التبويب حتى لا نقاطع جلسة جارية. الفكرة سليمة نظرياً، لكن القياس قال غير ذلك: الشريط يظهر لمستخدمة تعبّئ استمارة موعد، فتراه إعلاناً وتغلقه أو تتجاهله، ويبقى الكود القديم يعمل. فحذفناه بالكامل — الشريط وأنماطه في CSS ومتغيّراته في الكود — واستبدلناه بتحديث صامت: skipWaiting فوري، ثم إعادة تحميل واحدة عند لحظة تسليم التحكّم. المستخدمة ترى نسخة جديدة بلا سؤال ولا انتظار.
ما جعل هذا آمناً هو الحرس المكتوب حول controllerchange. الحدث نفسه لا يميّز أي Service Worker تولّى التحكّم، وفي نطاقنا يوجد اثنان: الرئيسي، وآخر للإشعارات. فبلا شرط كان كل تبديل يُنتج إعادة تحميل، وإعادة التحميل تُنتج تبديلاً آخر — حلقة لا تنتهي. الشرط الفعلي:
const _isMainSW = (sw) => !!(sw && /\/sw\.js(\?|$)/.test(sw.scriptURL || ''));
ومعه علامتان: راية تمنع تكرار إعادة التحميل أكثر من مرة، وفحص هل كان هناك متحكّم لحظة التحميل أصلاً — لأن أول زيارة في العمر تُنتج controllerchange طبيعياً، وإعادة تحميل الصفحة عندها استقبال سيّئ لزائرة جديدة.
خطر: لا تُسلّم مفاتيح نطاقك لطرف ثالث
كثير من خدمات الإشعارات والإعلانات تطلب منك خطوة تبدو تافهة: ضع ملفاً في جذر الموقع يستورد سكربتها، أو أضف سطر importScripts('https://...') إلى الـ Service Worker الحالي. افهم ما تعنيه هذه الموافقة: الـ Service Worker يعترض كل طلبات نطاقك عبر معالج fetch، فيرى المسارات والمعاملات والاستجابات ويستطيع تعديلها أو تخزينها أو استبدالها. سطر importScripts واحد يمنح كوداً لا تملكه — ويتحدّث تلقائياً على خادمه بلا مراجعة منك — هذه السلطة كاملةً على نطاقك.
وله ثمن وظيفي أيضاً. عندنا يوجد Service Worker ثانٍ للإشعارات في النطاق نفسه، وكان يكفي أن ينادي clients.claim() ليسحب التحكّم من الرئيسي فتبدأ حلقة إعادة التحميل. تركناه بلا claim عن قصد، لأنه لا يحتاج التحكّم بالطلبات — يحتاج معالج الرسائل الخلفية والنقر على الإشعار فقط، وكلاهما يعمل بدونه. لذلك تُقيّد سياسة أمان المحتوى عندنا مصادر العاملين بـ worker-src 'self' blob:، أي لا نطاق خارجي يشغّل عاملاً على موقعنا مهما كان مقنعاً. وإن احتجت خدمة إشعارات من طرف ثالث، فليكن لها ملفها المستقل بنطاق (scope) محدود لا يغطّي الموقع كله.
6. الحبل الاحتياطي: استعلام version.json
آلية الـ Service Worker كافية على Chrome و Edge، لكن سلوك سفاري على iOS كان أبطأ في تسليم controllerchange، وبعض التبويبات تبقى مفتوحة أياماً بلا تحميل جديد. فأضفنا مساراً ثانياً مستقلاً تماماً عن الـ Service Worker: ملف صغير جداً يُقرأ دورياً.
{ "version": "20260809d", "released": "2026-08-09", "notes": "..." }
وله رأسه الخاص على الخادم: no-cache, no-store, must-revalidate، وحزام أمان ثانٍ في الكود لأن بعض متصفحات الجوال تتحايل على الرؤوس:
const r = await fetch('./version.json?t=' + Date.now(), { cache: 'no-store' });
المنطق مقصود ودقيق: أول قراءة تُخزَّن كإصدار معروف ولا تُطلق شيئاً (وإلّا لأعدنا تحميل كل صفحة عند أول فتح). ما بعدها، أي اختلاف يعني أن نشراً جديداً وصل، فنطلب من التسجيل تحديث نفسه، وإن لم يتم شيء خلال ثلاث ثوانٍ نُعيد تحميل الصفحة بأنفسنا. الفحص يجري كل خمس دقائق، وأيضاً عند رجوع التبويب للظهور، وعند عودته للتركيز، وعند رجوع الإنترنت بعد انقطاع — لأن هذه اللحظات الثلاث هي حين تعود المستخدمة فعلياً. أما التسجيل نفسه فيُطلب تحديثه كل نصف ساعة وعند اللحظات ذاتها.
هل هذا مكلف؟ الملف نحو 70 بايت، وهو ملف ثابت من الاستضافة لا استعلام قاعدة بيانات — فلا يستهلك حصة قراءات ولا يظهر في الفاتورة. أرخص بكثير من رسالة شكوى واحدة.
7. ماذا يُكاش بلا حدود وماذا يجب أن يكون طازجاً
القاعدة المطبَّقة في المشروع بسيطة: ما يمكن تمييز إصداره في عنوانه يُكاش بأقصى مدة، وما تحتاجه لكشف وجود إصدار جديد لا يُكاش إطلاقاً. وهذا تفصيل ما استقرّت عليه الرؤوس:
- JS و CSS والخطوط: سنة كاملة مع
immutable— آمنة لأن كل تغيير يغيّر عنوانها. - الصور: ثلاثون يوماً (
max-age=2592000) — مدة أقصر لأنها تُستبدل أحياناً باسمها نفسه. - HTML:
no-cache, must-revalidate— لأنه الملف الذي يحمل معاملات?v=كلها. كاش HTML لساعة واحدة يعني تجميد الإصدار القديم ساعةً كاملة رغم صحّة كل شيء آخر. - ملفات الـ Service Worker و
version.json:no-cache, no-store, must-revalidate— هذان هما جرس التحديث، وكاشهما يعني جرساً معطّلاً. - ملف الـ manifest: ساعة واحدة مع
must-revalidate— حلّ وسط، فهو يتغيّر نادراً.
وعلى مستوى الـ Service Worker نفسه، لكل نوع استراتيجية مختلفة داخل معالج fetch. طلبات التنقّل و HTML تعمل بـ «الشبكة أولاً» مع نسخة احتياطية من المخزن حين ينقطع الاتصال — وبهذا نضمن أن الصفحة الحاملة لأرقام الإصدارات طازجة دائماً، ومع ذلك يعمل الموقع دون إنترنت. بقية الأصول تعمل بـ stale-while-revalidate: تُقدَّم من المخزن فوراً وتُحدَّث بالخلفية بصمت. وفي التخزين شرط لا يُنسى:
if (net && net.status === 200 && (net.type === 'basic' || net.type === 'cors')) c.put(e.request, net.clone());
بدون فحص type ستخزّن استجابات معتمة (opaque) قادمة من نطاقات خارجية لا تعرف حالتها — قد تكون خطأً 404 مغلّفاً — فتقدّمها لاحقاً وكأنها سليمة. أضِف إلى ذلك ثلاثة استثناءات: طلبات غير GET نتركها للشبكة، ونطاقات Firebase وواجهات جوجل نتجاوزها كلياً لأنها تحتاج نسخة حيّة دائماً (وكاش رمز مصادقة كارثة صغيرة)، وأي عنوان لا يبدأ بروتوكوله بـ http نتجاهله لأن إضافات المتصفح تُصدر طلبات بروتوكولات أخرى تُفسد المعالج.
أسئلة شائعة
حذفتُ كاش المتصفح ولم يتغيّر شيء — لماذا؟ لأن الـ Service Worker مخزن منفصل عن كاش المتصفح، وهو من يجيب على الطلبات قبل أن تصل الشبكة. الحلّ في الأدوات: تبويب Application ثم Service Workers ثم Unregister (أو Update on reload). أما في الإنتاج فلا يمكنك أن تطلب هذا من مستخدمة — لذلك تُبنى آلية إصدارات من الأصل.
هل أستخدم skipWaiting دائماً أم أُعلم المستخدم؟ إن كان تطبيقك يحفظ حالة طويلة في الصفحة (محرّر، استمارة طويلة، رفع ملف)، فأعلِمه وأجّل التطبيق للحظة أكثر أماناً. أما في موقع محتوى وتنقّل، فالتحديث الصامت أفضل: قِسنا الطريقتين ورأينا أن الشريط يُتجاهل، فحذفناه.
ما الفرق بين ?v= وبصمة المحتوى في اسم الملف؟ الاثنان يحلّان المشكلة نفسها. البصمة (مثل app.9f3c1a.js) أنظف لأن أداة البناء تحسبها من المحتوى فلا يمكن نسيانها، لكنها تفرض خطوة بناء تكتب أسماء الملفات في HTML. المعامل اليدوي أبسط ويعمل بلا أدوات، وثمنه أنه يعتمد على انتباهك — ولهذا وضعنا قائمة مراجعة نشر بجانب الكود.
لماذا لا يكفي reg.update() وحده؟ لأنه يفحص ملف الـ Service Worker فقط. إن نسيت رفع ?v= فلن يجد المتصفح سبباً لإعادة تنزيل ملف JS عنوانه لم يتغيّر ومدّته سنة. لهذا يبقى معامل الإصدار في HTML شرطاً لا بديل عنه.
خلاصة
«تعديلي لا يظهر» ليس خطأً غامضاً، بل نتيجة طبيعية لعقد أبرمتَه أنت مع المتصفح ثم نسيته. ما نجح معنا: ثابت VERSION واحد يُبنى عليه اسم المخزن، ومزامنة إلزامية بينه وبين معاملات ?v= في HTML وحقل version في ملف الإصدار، ورؤوس صريحة تفصل بين ما يُكاش سنةً وما لا يُكاش لحظة، وفهم أن حالة waiting ليست عطلاً بل حماية، وحبل احتياطي يقرأ ملفاً بحجم 70 بايت لأن متصفحاً واحداً لا يتصرّف كالبقية. أضِف إلى ذلك قراراً واحداً لا تتنازل عنه: لا تمنح كوداً لا تملكه سلطةً على كل طلبات نطاقك مقابل ميزة إشعارات.