الأدلة التقنية

بيئة التطوير المحلية: كيف تحاكي البريد وDNS وHTTPS على جهازك

لماذا تستبدل البريد وDNS وHTTPS ببدائل محلية أثناء التطوير، وكيف يعامل المتصفح localhost، وأي النطاقات تتجنب.

بيئة التطوير المحلية: كيف تحاكي البريد وDNS وHTTPS على جهازك

عندما تكتب ميزة «نسيت كلمة المرور» في تطبيقك فإن الكود لا يعمل وحده، فهو يرسل بريداً عبر سيرفر SMTP، ويفتح رابطاً على نطاق له شهادة HTTPS، وربما يرفع ملفاً إلى تخزين S3، أو ينتظر Webhook من بوابة الدفع، أو يحول المستخدم إلى Google للدخول عبر OAuth. وفي الإنتاج Production كل هذه خدمات حقيقية، أما على جهازك فأنت أمام سؤال يتكرر مع كل مشروع: هل أربط جهازي بالخدمات الحقيقية، أم أشغل بديلاً محلياً يتصرف مثلها؟ وكيف أختبر ميزة تحتاج HTTPS وأنا أعمل على localhost؟

وهذا المقال هو الأول في سلسلة من ثلاثة أجزاء عن بيئة التطوير المحلية Local Development Environment، والمواضيع في السلسلة:

  • بيئة التطوير المحلية: الفكرة، وما الذي تحاكيه، ولماذا (الموضوع الحالي).
  • أدوات بيئة التطوير المحلية: صناديق التقاط البريد، وشهادات HTTPS المحلية، وDNS المحلي، ومحاكيات السحابة وال API، وأنفاق ال Webhooks، وTestcontainers وDev Containers.
  • التطبيق العملي: ملف Docker Compose واحد فيه التطبيق وMailpit وCaddy وDNS محلي، واختبار آلي يتأكد أن بريد استعادة كلمة المرور يصل.

ولاحظ أن هذه السلسلة تبدأ من جهاز المطور، أما ما بعده من بيئات dev وstaging وprod وبيئات المعاينة لكل طلب دمج فقد شرحناه في مقال البيئات وGitOps للفرق، ولن نكرره هنا.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا لا تربط جهاز التطوير بالخدمات الحقيقية، وما الذي حدث عندما وصلت رسالة اختبار إلى مشتركين حقيقيين.
  • التطابق مع الإنتاج Dev/Prod Parity مقابل سرعة التطوير، ودرجات المحاكاة من Mock داخل الكود إلى بيئة Sandbox عند المزود.
  • خريطة الخدمات الخارجية وما يقابل كل منها على جهازك.
  • الأسماء المحلية: localhost و.test و.internal، والأسماء التي تتجنبها مثل .local و.dev.
  • السياق الآمن Secure Context: لماذا تعمل Passkeys وService Workers على localhost دون شهادة، ولا تعمل على عنوان IP في الشبكة.
  • تجربة عملية تفتح الخادم نفسه بسبعة عناوين وتقيس ما يسمح به المتصفح في كل منها، ثم نكسرها.
  • المخاطر: البريد الحقيقي، والمفاتيح الحقيقية في ملف .env، وجهة الإصدار المحلية التي تثق بها.

لماذا لا تستخدم الخدمات الحقيقية من جهازك؟

الحل البسيط الذي يبدأ به أغلب المطورين أن يضعوا في ملف .env على أجهزتهم إعدادات حساب SMTP حقيقي، ومفاتيح S3 حقيقية، ومفتاح بوابة الدفع، لأن هذا أسرع طريق لترى الميزة تعمل. وقد يبدو ذلك من الوهلة الأولى منطقياً، فأنت تختبر على الخدمة نفسها التي سوف يستخدمها الإنتاج، لكن في الواقع هذا الإعداد يسبب خمس مشكلات تظهر واحدة بعد الأخرى:

  • الأثر الحقيقي Side Effects: كل تجربة على ميزة البريد ترسل رسالة حقيقية، وأي خطأ في قائمة المستلمين أو في البيانات التجريبية يصل إلى أشخاص حقيقيين، وكذلك الحال مع رسائل SMS وعمليات الدفع.
  • البيانات المشتركة Shared State: عندما يشترك فريق كامل في Bucket واحد أو حساب بريد واحد فإن ملفات زميلك ورسائله تختلط بنتائج اختبارك، والاختبار الذي نجح على جهازك يفشل على جهاز غيرك.
  • الحدود والتكلفة Rate Limits and Cost: المزود يحسب كل رسالة وكل طلب، ويحجب الحساب إذا تجاوزت الحد أو ارتفعت نسبة الرسائل المرتدة Bounces، وبالتالي قد يتوقف بريد الإنتاج بسبب تجارب التطوير إذا كان الحساب واحداً.
  • الأسرار على الأجهزة Secrets Sprawl: مفتاح حقيقي في ملف .env على كل جهاز في الفريق يعني أن تسريب جهاز واحد أو رفع الملف بالخطأ إلى Git يكشف حساب الإنتاج، وقد شرحنا ما يحدث بعد التسريب في مقال اكتشاف الأسرار المسربة.
  • الاعتماد على الشبكة: الاختبار الذي يحتاج الإنترنت يصبح بطيئاً ومتقلباً Flaky، ويتوقف إذا انقطع الاتصال أو تغيرت استجابة المزود.

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

في الوسط حاسوب المطور وعليه التطبيق، وعلى اليسار عمود الإنتاج فيه سيرفر SMTP حقيقي وDNS عام وشهادات Let's Encrypt وAWS S3 وبوابة دفع ترسل Webhooks ومزود دخول OAuth، وعلى اليمين عمود الجهاز المحلي فيه Mailpit وأسماء localhost وdnsmasq وجهة إصدار محلية mkcert أو Caddy وMinIO أو SeaweedFS وWireMock مع نفق Webhook وKeycloak أو authentik محلي، وبين العمودين سهم مكتوب عليه نفس البروتوكول وعنوان مختلف
الكود لا يعرف إن كان يتحدث مع الخدمة الحقيقية أو مع بديلها المحلي، والفرق كله في الإعداد
في 17 يونيو 2021 وصلت إلى جزء من مشتركي قائمة HBO Max البريدية رسالة عنوانها «Integration Test Email #1» ونصها سطر واحد يقول إن هذا القالب تستخدمه اختبارات التكامل فقط، واعتذرت الشركة في اليوم نفسه وقالت إنها أرسلت رسالة اختبار فارغة بالخطأ إلى جزء من القائمة، كما نقلت صحيفة Deseret News. والدرس هنا أن اختبار التكامل كان يملك طريقاً إلى قائمة المشتركين الحقيقية، ولو كان بريد الاختبارات يذهب إلى صندوق التقاط لا يرسل لأحد لما خرجت الرسالة أصلاً.

التطابق مع الإنتاج أم سرعة التطوير؟

مبدأ Dev/Prod Parity من منهجية The Twelve-Factor App يطلب أن تبقى بيئة التطوير قريبة من الإنتاج قدر الإمكان، ويحذر صراحة من استخدام خدمة مختلفة في التطوير، مثل SQLite محلياً وPostgreSQL في الإنتاج، لأن الفروق الصغيرة بين الخدمتين تظهر كأخطاء في الإنتاج فقط. وفي المقابل تريد أنت بيئة تعمل في ثوان، ودون إنترنت، ودون حسابات، ودون أن يؤثر خطؤك على أحد. وقد يتساءل البعض: أليس هذان طلبين متعارضين؟ والإجابة أن المحاكاة درجات، وأن لكل خدمة درجة مناسبة، والصورة التالية تبين هذه الدرجات:

سلم من أربع درجات من اليمين إلى اليسار: الأولى Mock داخل الكود وهي الأسرع وأبعدها عن الإنتاج، والثانية سيرفر بديل يتحدث البروتوكول نفسه مثل Mailpit وMinIO وWireMock، والثالثة البرنامج الحقيقي نفسه في Container مثل PostgreSQL وKeycloak، والرابعة بيئة Sandbox عند المزود مثل وضع الاختبار في بوابة الدفع وهي الأقرب للإنتاج والأبطأ، وتحت السلم سهمان أحدهما للسرعة والعزل والآخر للتطابق مع الإنتاج
كلما صعدت درجة اقتربت من الإنتاج، ودفعت ثمن ذلك في السرعة والعزل
  1. بديل داخل الكود Mock أو Fake: كلاس يحل محل مرسل البريد في اختبار الوحدة Unit Test ويحفظ الرسالة في قائمة، وهو الأسرع، لكنه لا يختبر SMTP ولا الترميز ولا المرفقات، وقد شرحنا الفرق بين Mock وStub وFake في قسم البدائل في مقال أنواع الاختبارات.
  2. سيرفر بديل يتحدث البروتوكول نفسه: مثل Mailpit الذي يستقبل SMTP حقيقياً، أو MinIO وSeaweedFS اللذين يتحدثان واجهة S3، أو WireMock الذي يرد على طلبات HTTP كما يرد ال API الخارجي، وهنا يعمل كود الإرسال الحقيقي ومكتبته كما هي، والذي يتغير هو أن الرسالة لا تخرج.
  3. البرنامج الحقيقي في Container: قاعدة البيانات نفسها بالإصدار نفسه، أو مزود الهوية نفسه مثل Keycloak أو authentik، وهذا هو الخيار الصحيح لكل خدمة تملكها أنت في الإنتاج، فلا يوجد سبب لمحاكاة PostgreSQL وأنت تستطيع تشغيله.
  4. بيئة Sandbox عند المزود: مثل وضع الاختبار في بوابة الدفع، وهي الأقرب لسلوك المزود الحقيقي، لكنها تحتاج حساباً وإنترنت، وتتشارك فيها الفريق، وتحتاج طريقاً تصل منه ال Webhooks إلى جهازك.

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

ما الذي تحاكيه لكل خدمة خارجية؟

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

الخدمةفي الإنتاجعلى جهازكما لا يختبره البديل
البريد الصادرسيرفر SMTP أو خدمة إرسالصندوق التقاط Mail Catcher مثل Mailpitوصول الرسالة فعلاً، وسمعة النطاق، وسجلات SPF وDKIM
أسماء النطاقاتسجلات حقيقية في DNS عامأسماء تنتهي بـ .localhost أو DNS محلي لنطاق .testانتشار السجلات ومدة TTL والتخزين المؤقت عند المزودين
شهادات HTTPSجهة إصدار عامة مثل Let's Encryptجهة إصدار محلية Local CA مثل mkcert أو Caddyالتحقق من ملكية النطاق، وتجديد الشهادات من الخارج
تخزين الملفاتخدمة AWS S3 أو مزود متوافقسيرفر MinIO أو SeaweedFS أو Garageسياسات IAM الحقيقية وبعض ميزات المزود
الدفع وال Webhooksبوابة دفع ترسل Webhooks إلى نطاقكوضع الاختبار عند المزود مع نفق Tunnel، أو ردود مسجلة في WireMockتوقيت الإرسال الحقيقي وإعادة المحاولة عند المزود
رسائل SMSمزود رسائلسيرفر HTTP بديل يسجل الطلب بدل إرسالهوصول الرسالة إلى الهاتف
الدخول عبر OAuth وOIDCحساب Google أو Microsoft أو مزود هوية الشركةمزود هوية في Container مثل Keycloak أو authentikسياسات المزود الخارجي وشاشة الموافقة عنده
خدمات السحابةخدمات مثل SQS وSNS وLambdaمحاكي مثل LocalStack أو Motoالأداء والحدود والصلاحيات كما هي عند المزود

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

البريد: صندوق التقاط بدل سيرفر حقيقي

صندوق التقاط البريد Mail Catcher هو سيرفر SMTP يقبل كل رسالة تصله ويحفظها ويعرضها في واجهة ويب، ولا يرسلها إلى أي مستلم، وأشهر أداة لذلك اليوم هي Mailpit. وقد استخدمناه في عدد من التجارب على هذا الموقع دون أن نشرحه بالتفصيل، فهو «السيرفر الآخر» في مختبر SMTP وIMAP، والسيرفر الذي التقط حملة الاختبار في دليل listmonk، ودعوات المستخدمين في دليل Chatwoot، ورسائل الحجز في دليل Cal.com. والفكرة في كل هذه التجارب واحدة: التطبيق يرسل عبر SMTP كما يرسل في الإنتاج، والفرق أن العنوان هو mailpit:1025 بدل سيرفر المزود.

والأهم من الواجهة أن صندوق الالتقاط يملك API تقرأ منه الاختبارات الآلية، فبدل أن تفتح الواجهة وتتأكد بعينك أن رسالة استعادة كلمة المرور وصلت، يكتب الاختبار طلباً إلى ال API ويبحث عن الرسالة ويستخرج الرابط منها ويفتحه، وهذا ما سوف نبنيه في الجزء الثالث. أما شرح طريق الرسالة في SMTP نفسه فموجود في مقال كيف يعمل البريد الإلكتروني، وشرح البريد الذي يرسله التطبيق في الإنتاج موجود في مقال Transactional Email.

الأسماء المحلية: أي نطاق تستخدم على جهازك؟

أغلب المطورين يبدأون بـ localhost:3000 وlocalhost:8025، وهذا يكفي لتطبيق واحد، لكنه يتعب عندما يكون لديك تطبيق وواجهة إدارة وسيرفر بريد وتخزين، فكل منها على منفذ مختلف، وملفات تعريف الارتباط Cookies لا تفصل بين المنافذ، والسبب أن ال Cookie مرتبط بالاسم وليس بالمنفذ كما شرحنا في قسم خصائص ال Cookies، وبالتالي تطبيقان على localhost بمنفذين مختلفين يريان ملفات بعضهما. والحل أن يكون لكل خدمة اسم، والسؤال: أي اسم؟

هنا تحتاج إلى معرفة أن بعض النطاقات محجوزة بمعايير، فلا يمكن لأحد أن يشتريها ولن تتعارض مع موقع حقيقي، وبعضها يبدو مناسباً لكنه يسبب مشكلات:

النطاقحالتههل يناسب التطوير؟
localhost وكل ما ينتهي بـ .localhostمحجوز في RFC 6761، ويشير دائماً إلى جهازك نفسه Loopbackنعم، وهو الخيار الأول لأن المتصفح يعامله كسياق آمن دون شهادة
.testمحجوز للاختبار في RFC 2606 وRFC 6761، ولا يباعنعم، عندما تحتاج اسماً يشير إلى غير جهازك، مثل Container أو جهاز آخر في الشبكة، ويحتاج DNS محلياً
.example وexample.comمحجوزة للتوثيق والأمثلةللأمثلة وعناوين البريد التجريبية فقط
.internalحجزته ICANN في قرار مجلسها في 29 يوليو 2024 للاستخدام الخاص، ولن يضاف إلى DNS العاممناسب للشبكات الداخلية في الشركة أكثر من جهاز المطور
.localمخصص لبروتوكول mDNS في RFC 6762، الذي تعلن به الطابعات والأجهزة عن نفسهالا، لأن الأنظمة ترسل أسئلته إلى mDNS وليس إلى DNS العادي، فتتأخر الإجابة أو تفشل
.devنطاق حقيقي يباع تديره Google Registry، وكله على قائمة HSTS Preloadلا، فالمتصفح يرفض أي اتصال به عبر HTTP، كما سوف نرى في التجربة
نطاق شركتك الحقيقيمسجل باسمكنعم إذا كنت تملكه وتديره، مثل dev.example-company.com، ولا تستخدم أبداً نطاقاً لا تملكه

ويجدر الإشارة هنا إلى أن RFC 6761 يقول إن مكتبات تحويل الأسماء Name Resolution APIs «SHOULD» تعامل أسماء localhost معاملة خاصة وتعيد عنوان Loopback دائماً، وكلمة SHOULD في المعايير تعني «ينبغي» وليس «يجب»، ولهذا لا تتصرف كل البرامج على جهازك بالطريقة نفسها مع app.localhost، وهذا ما سوف تراه بعينك في التجربة. أما ملف /etc/hosts فيربط أسماء كاملة بعناوين، ولا يفهم النجمة Wildcard، فلا يمكنك أن تكتب فيه *.lab.test وتتوقع أن يعمل كل اسم تحته، ولهذا تحتاج DNS محلياً عندما يكون لديك أسماء كثيرة، وهذا موضوع المقال الثاني.

لماذا تعمل Passkeys على localhost دون شهادة؟

المتصفحات الحديثة تقصر عدداً من الميزات على ما يسمى السياق الآمن Secure Context، أي الصفحات التي وصلت عبر HTTPS، والسبب أن هذه الميزات خطيرة إذا استطاع أحد في الطريق أن يعدل الصفحة، ومنها:

  • واجهة Service Workers التي تعمل في الخلفية وتعترض طلبات الصفحة، والتي تحتاجها الإشعارات الفورية Push Notifications والتطبيقات التي تعمل دون إنترنت.
  • واجهة WebAuthn التي تقوم عليها Passkeys، وواجهة التشفير crypto.subtle، والكاميرا والميكروفون في WebRTC.
  • ملفات تعريف الارتباط بالخاصية Secure، فبحسب توثيق Set-Cookie في MDN لا يستطيع موقع على http: أن يضع Cookie فيه Secure، إلا إذا كان الموقع هو localhost.

وقد يتساءل البعض: إذا كانت هذه الميزات تحتاج HTTPS، فكيف نطور عليها دون شهادة؟ والإجابة في خوارزمية «هل هذا الأصل موثوق؟» في معيار Secure Contexts من W3C، فهي تعتبر الأصل Origin موثوقاً إذا كان عنوانه في 127.0.0.0/8 أو ::1، أو إذا كان اسمه localhost أو ينتهي بـ .localhost، بشرط أن يضمن المتصفح أن هذه الأسماء لا تذهب أبداً إلى غير جهازك. والسبب أن الخطر الذي يحمي منه HTTPS هو أن يعدل أحد الصفحة في الطريق، والطلب إلى Loopback لا يخرج من جهازك أصلاً، فلا يوجد طريق يقف فيه أحد. ولاحظ الشرط الأخير، فالمعيار لا يثق بالاسم لأنه يشير إلى 127.0.0.1 اليوم، وإنما لأن المتصفح نفسه يضمن أنه لن يشير إلى غيره أبداً، وهذا يعني أن اسماً مثل dev.lab.test لا يصبح آمناً حتى لو كتبته في ملف hosts ليشير إلى 127.0.0.1. والصورة التالية تلخص ذلك:

جدول مرسوم فيه سبعة عناوين للخادم نفسه، أمام كل عنوان علامة خضراء أو حمراء: http://127.0.0.1 وhttp://localhost وhttp://app.localhost وhttp://shop.app.localhost بعلامة خضراء، وhttp://172.17.0.2 وhttp://dev.lab.test بعلامة حمراء مع ملاحظة أن الثاني يشير إلى 127.0.0.1 ومع ذلك ليس آمناً، وhttps://app.lab.test بشهادة من جهة إصدار محلية موثوقة بعلامة خضراء، وفي الأعلى أيقونات الميزات التي تفتح في السياق الآمن: Service Worker وSecure Cookie وcrypto.subtle وWebAuthn
الاسم هو الذي يحدد السياق الآمن، وليس العنوان الذي يشير إليه في ملف hosts

تجربة عملية: الخادم نفسه بسبعة عناوين

الآن سوف نختبر الكلام السابق بأنفسنا، فنكتب خادماً صغيراً بلغة Node.js دون أي شهادة، يضع في كل استجابة Cookie بالخاصية Secure، ويعرض صفحة تسأل المتصفح عن الميزات المتاحة، ثم نفتحه بسبعة عناوين في متصفح Chromium نظيف في كل مرة. والتجربة كلها داخل Container واحد، فلا نغير أي شيء على جهازك، ولا نلمس ملف hosts الخاص بك، والمتصفح نفسه يعمل بملف شخصي Profile مؤقت يحذف مع ال Container.

الخادم في الملف server.mjs:

// One tiny HTTP server (no TLS) that we open under several names to see how the browser treats each origin.
import { createServer } from 'node:http';

const page = `<!doctype html><meta charset="utf-8"><title>probe</title>
<script>
window.probe = async () => ({
  origin: location.origin,
  isSecureContext: window.isSecureContext,
  serviceWorker: 'serviceWorker' in navigator,
  cryptoSubtle: !!(window.crypto && crypto.subtle),
  webauthn: typeof PublicKeyCredential !== 'undefined',
  secureCookieKept: document.cookie.includes('probe=1'),
});
</script>`;

createServer((req, res) => {
    res.setHeader('Set-Cookie', 'probe=1; Secure; Path=/; SameSite=Lax');
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.end(page);
}).listen(8080, '0.0.0.0', () => console.log('probe server on :8080'));

والملف probe.mjs يفتح كل عنوان في سياق متصفح جديد Browser Context حتى لا تنتقل ال Cookies من عنوان إلى آخر، ويطبع ما وجده:

import { chromium } from 'playwright';

// myapp.dev is mapped to 127.0.0.1 inside this browser only, to see what the HSTS preload list does
const browser = await chromium.launch({ args: ['--host-resolver-rules=MAP myapp.dev 127.0.0.1'] });
console.log('chromium', browser.version());
for (const url of process.argv.slice(2)) {
    const ctx = await browser.newContext();            // a new, empty profile for every origin
    const page = await ctx.newPage();
    try {
        await page.goto(url, { timeout: 8000 });
        console.log(JSON.stringify(await page.evaluate(() => window.probe())));
    } catch (e) {
        console.log(JSON.stringify({ url, error: e.message.split('\n')[0] }));
    }
    await ctx.close();
}
await browser.close();

نشغل الخادم في Container من Docker Image مبنية على mcr.microsoft.com/playwright:v1.64.0-noble ومعها مكتبة Playwright 1.64.0، وهي نفس ال Image التي سوف نستخدمها في الجزء الثالث وتجد ملف بنائها هناك، ونضيف داخل ال Container فقط الاسم dev.lab.test ليشير إلى 127.0.0.1:

docker run -d --name lab-ldev-probe -v "$PWD:/tests/probe:ro" \
  --add-host dev.lab.test:127.0.0.1 lab-ldev-pw:1 node /tests/probe/server.mjs

docker exec -w /tests/probe lab-ldev-probe node probe.mjs \
  http://127.0.0.1:8080/ http://localhost:8080/ http://app.localhost:8080/ \
  http://shop.app.localhost:8080/ http://172.17.0.2:8080/ \
  http://dev.lab.test:8080/ http://myapp.dev:8080/

والمخرج سوف يكون كما يلي:

chromium 156.0.8078.4
{"origin":"http://127.0.0.1:8080","isSecureContext":true,"serviceWorker":true,"cryptoSubtle":true,"webauthn":true,"secureCookieKept":true}
{"origin":"http://localhost:8080","isSecureContext":true,"serviceWorker":true,"cryptoSubtle":true,"webauthn":true,"secureCookieKept":true}
{"origin":"http://app.localhost:8080","isSecureContext":true,"serviceWorker":true,"cryptoSubtle":true,"webauthn":true,"secureCookieKept":true}
{"origin":"http://shop.app.localhost:8080","isSecureContext":true,"serviceWorker":true,"cryptoSubtle":true,"webauthn":true,"secureCookieKept":true}
{"origin":"http://172.17.0.2:8080","isSecureContext":false,"serviceWorker":false,"cryptoSubtle":false,"webauthn":false,"secureCookieKept":false}
{"origin":"http://dev.lab.test:8080","isSecureContext":false,"serviceWorker":false,"cryptoSubtle":false,"webauthn":false,"secureCookieKept":false}
{"url":"http://myapp.dev:8080/","error":"page.goto: net::ERR_SSL_PROTOCOL_ERROR at http://myapp.dev:8080/"}

في المخرج أعلاه لاحظ التالي:

  • العناوين الأربعة الأولى سياق آمن دون أي شهادة، وفيها Service Worker وWebAuthn وcrypto.subtle، والمتصفح احتفظ بال Cookie الذي يحمل Secure رغم أن الاتصال HTTP عادي، وهذا هو الاستثناء الذي يسمح لك بتطوير Passkeys على app.localhost كما فعلنا في تجربة Passkeys.
  • الاسم shop.app.localhost بمستويين يعمل أيضاً، وهذا مفيد للتطبيقات متعددة المستأجرين Multi-tenant التي تعطي كل عميل نطاقاً فرعياً.
  • العنوان 172.17.0.2 هو عنوان ال Container في شبكة Docker، وهو يمثل ما يحدث عندما تفتح تطبيقك من جهاز آخر أو من هاتفك عبر عنوان الشبكة، فكل الميزات اختفت، ولم يحفظ المتصفح ال Cookie، وتطبيقك الذي يحفظ الجلسة في Cookie آمن لن يسجل دخول أحد.
  • الاسم dev.lab.test يشير إلى 127.0.0.1 نفسه، ومع ذلك ليس سياقاً آمناً، وهذا ما ذكرناه قبل قليل: المتصفح يثق بالاسم الذي يضمن هو أنه محلي، وليس بما يقوله ملف hosts.

نكسرها: النطاق .dev والأسماء التي لا تفهمها كل البرامج

السطر الأخير في المخرج هو الكسر الأول، فقد جعلنا المتصفح نفسه يحول myapp.dev إلى 127.0.0.1، وطلبنا http:// صراحة، لكن Chromium لم يرسل طلب HTTP أصلاً، وإنما حول الطلب إلى HTTPS قبل أن يخرج لأن النطاق .dev كله في قائمة HSTS Preload المدمجة في المتصفح، ثم فشل لأن الخادم لا يتحدث TLS، وهذا هو الخطأ ERR_SSL_PROTOCOL_ERROR الذي يراه كل من يختار .dev لتطويره المحلي. ولا يوجد إعداد في الخادم يصلح ذلك، والحل الوحيد ألا تستخدم .dev إلا إذا كنت تملك النطاق وتريد HTTPS.

والكسر الثاني أن app.localhost لا يعمل في كل برنامج على جهازك، فالمعيار يقول «SHOULD» كما ذكرنا، والأمر التالي يسأل ثلاث أدوات مختلفة داخل ال Container نفسه:

docker exec lab-ldev-probe sh -c '
  getent hosts app.localhost || echo "getent: app.localhost not found (exit $?)"
  node -e "require(\"dns\").lookup(\"app.localhost\",(e,a)=>console.log(e?e.code:a))"
  curl -sv http://app.localhost:8080/ -o /dev/null 2>&1 | grep -E "Trying|Connected"'

والمخرج سوف يكون كما يلي:

getent: app.localhost not found (exit 2)
EAI_AGAIN
*   Trying [::1]:8080...
*   Trying 127.0.0.1:8080...
* Connected to app.localhost (127.0.0.1) port 8080

في المخرج أعلاه لاحظ التالي:

  • الأمر getent يستخدم مكتبة glibc 2.39 في Ubuntu 24.04 مباشرة، وهي هنا لا تعرف app.localhost لأن ملف /etc/hosts فيه localhost وحده، ولا يوجد في ال Container خدمة systemd-resolved التي تعيد Loopback لكل اسم ينتهي بـ .localhost.
  • وبيئة Node.js تستخدم المكتبة نفسها في dns.lookup، فأرسل السؤال إلى DNS وفشل بالخطأ EAI_AGAIN، وهذا يعني أن الكود الذي يتصل من الخادم إلى api.localhost مثلاً لن يعمل هنا، حتى لو عمل الاسم نفسه في المتصفح.
  • أداة curl 8.5.0 تعامل .localhost بنفسها دون أن تسأل النظام كما يشرح دليل curl، فاتصلت ب ::1 ثم 127.0.0.1، وChromium يفعل الشيء نفسه.

والخلاصة من هذا الكسر أن *.localhost ممتاز لما يفتحه المتصفح، لكنه لا يصلح اسماً تتصل به خدمة من خدمة أخرى، وعندما تحتاج أسماء تعمل في المتصفح وفي الكود وفي ال Containers معاً تحتاج DNS محلياً يجيب عن نطاق مثل lab.test، ثم تحتاج HTTPS عليه لأنه ليس سياقاً آمناً كما رأينا.

ولإزالة التجربة:

docker rm -f lab-ldev-probe

متى لا يكفيك localhost وتحتاج HTTPS محلياً؟

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

  • الاختبار من جهاز آخر: فتح التطبيق من هاتفك أو من جهاز زميل عبر عنوان الشبكة، وقد رأينا في التجربة أن السياق الآمن يختفي.
  • الخدمات تتصل ببعضها بالأسماء: كما رأينا في الكسر الثاني، فالاسم الذي تستخدمه الخدمات بينها يجب أن يكون اسماً يجيب عنه DNS، وليس .localhost.
  • ال Cookies العابرة للمواقع: ال Cookie بالخاصية SameSite=None يجب أن يحمل Secure، وهذا يظهر عندما يكون للواجهة وال API اسمان مختلفان.
  • السلوك المرتبط بالبروتوكول: المحتوى المختلط Mixed Content، والتحويل من HTTP إلى HTTPS، وترويسة HSTS، والترويسات التي يضيفها ال Reverse Proxy، وكلها أخطاء لا تظهر إلا بعد النشر إذا كنت تطور على HTTP، وقد جمعنا أكثرها في مقال تطبيقك خلف Reverse Proxy.
  • مزودو الدخول OAuth: بعض المزودين يقبلون عنوان العودة Redirect URI على http://localhost فقط، ويطلبون HTTPS لأي اسم آخر، وتفاصيل التدفق في مقال OAuth وOpenID Connect.

وفي هذه الحالات تنشئ جهة إصدار محلية Local CA على جهازك، وتصدر منها شهادة لأسمائك المحلية، ثم تجعل المتصفح يثق بها، وهنا يبدأ الجزء الذي يحتاج حذراً، لأنك تضيف جهة إصدار جديدة إلى قائمة الجهات التي يثق بها جهازك، وقد شرحنا سلسلة الثقة ومخزن الثقة Trust Store في مقال الشهادات الرقمية.

المخاطر: ما الذي قد يخرج من جهازك؟

بيئة التطوير المحلية تبدو مكاناً لا يحتاج حماية لأنها على جهازك، لكن هناك أربعة أخطاء تتكرر، وكل منها يخرج شيئاً من جهازك إلى العالم:

  1. بريد حقيقي من بيئة التطوير: كما في قصة HBO Max، والحماية هنا طبقتان: أن يرفض التطبيق نفسه أن يعمل في وضع التطوير بإعداد SMTP غير صندوق الالتقاط، وأن تكون الشبكة التي يعمل عليها لا تصل إلى الإنترنت أصلاً، وسوف نبني الطبقتين ونكسرهما في الجزء الثالث.
  2. مفاتيح حقيقية في ملف .env: ملف الإعداد المحلي يجب ألا يحتوي إلا قيماً محلية، ومفاتيح Sandbox عند الحاجة، ولا يرفع إلى Git، وإذا رفع مفتاح بالخطأ فاعتبره مكشوفاً وغيره فوراً، والسبب أن حذفه من آخر Commit لا يحذفه من التاريخ.
  3. جهة إصدار محلية تثق بها: المفتاح الخاص لجهة الإصدار المحلية يستطيع أن يصدر شهادة لأي موقع، مثل بنكك أو بريدك، وسوف يقبلها متصفحك، ولهذا يحذر توثيق mkcert من أن الملف rootCA-key.pem يعطي من يملكه قدرة كاملة على اعتراض الاتصالات الآمنة من جهازك، فلا تشاركه ولا تضعه في مستودع الفريق، ولكل مطور جهة إصدار خاصة به.
  4. خادم التطوير مفتوح للشبكة: خادم التطوير الذي يستمع على 0.0.0.0 يراه كل من على شبكة المقهى أو الفندق، ومعه لوحة صندوق البريد وقاعدة البيانات، فاربط الخدمات ب 127.0.0.1 كما شرحنا في الدرجة الأولى من مقال حماية الأدوات الداخلية، وعندما تفتح نفقاً Tunnel لتستقبل Webhook تذكر أن النفق ينشر تطبيقك على الإنترنت كله كما ذكرنا في مقال الأنفاق، فأغلقه عندما تنتهي.

أخطاء شائعة

  • اختبار البريد بحساب Gmail الشخصي: الرسائل تخرج فعلاً، والحساب قد يحجب، وكلمة مرور التطبيق في ملف .env، والحل صندوق التقاط.
  • استخدام .dev أو .local للتطوير: الأول يفرض HTTPS بقائمة HSTS، والثاني يذهب إلى mDNS، والبديل .localhost أو .test.
  • توقع أن يعمل app.localhost في كل مكان: يعمل في المتصفح وcurl، ولا يعمل بالضرورة في الكود الذي يستخدم مكتبة النظام.
  • استخدام Mock لكل شيء: محاكاة PostgreSQL أو Redis وأنت تستطيع تشغيلهما في Container تخفي أخطاء لا تظهر إلا في الإنتاج.
  • بديل محلي دون اختبار عند المزود: صندوق الالتقاط لا يختبر وصول البريد، ومحاكي السحابة لا يختبر الصلاحيات الحقيقية، فاختبر مرة في staging قبل الإصدار.
  • مشاركة جهة الإصدار المحلية بين الفريق: مفتاح واحد على أجهزة الجميع يعني أن تسريب جهاز واحد يكفي لاعتراض اتصالات الفريق كله.

الخلاصة

  • الكود يبقى كما هو بين جهازك والإنتاج، والإعداد وحده يحدد هل يتحدث مع الخدمة الحقيقية أم مع بديلها المحلي.
  • الخدمة التي تشغلها في الإنتاج تشغلها نفسها محلياً، والخدمة التي تشتريها تحاكيها بسيرفر يتحدث بروتوكولها، ثم تختبرها مرة عند المزود.
  • صندوق التقاط البريد مثل Mailpit يستقبل SMTP حقيقياً ولا يرسل لأحد، ويملك API تقرأ منه الاختبارات الآلية.
  • الأسماء المناسبة للتطوير هي .localhost للمتصفح و.test عندما تحتاج DNS، وتجنب .dev و.local.
  • المتصفح يعامل localhost و*.localhost و127.0.0.1 كسياق آمن دون شهادة، وفي التجربة لم يكن dev.lab.test آمناً رغم أنه يشير إلى 127.0.0.1.
  • تحتاج HTTPS محلياً عندما تفتح التطبيق من جهاز آخر، أو تتصل الخدمات ببعضها بالأسماء، أو تختبر سلوكاً مرتبطاً بالبروتوكول، ومفتاح جهة الإصدار المحلية هو أخطر ملف على جهازك بعد ذلك.

وفي المقال التالي بإذن الله سوف نقارن الأدوات التي تحقق كل ذلك، من صناديق التقاط البريد إلى Testcontainers وDev Containers، ثم نجمعها في الجزء الثالث في ملف Docker Compose واحد.

دليلأدوات بيئة التطوير المحلية: ماذا تستخدم لكل خدمة؟مقارنة بالقياس بين أدوات التقاط البريد والشهادات المحلية والأسماء ومحاكاة ال API والسحابة، وأيها تختار لمشروعك.دليلبيئة تطوير محلية كاملة بملف Docker Compose واحدنبني بيئة تطوير فيها البريد وHTTPS والأسماء المحلية، ونكتب اختباراً آلياً لبريد استعادة كلمة المرور ثم نكسرها.دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، والتجربة على Chromium 156.0.8078.4 من Playwright 1.64.0 داخل Ubuntu 24.04 (glibc 2.39) مع Node.js 24.21.0 وcurl 8.5.0 على Docker Engine 29.8.0، والمعلومات من RFC 6761 وRFC 2606 وRFC 6762 ومعيار Secure Contexts وتوثيق MDN وقرار ICANN بشأن .internal وصفحة Google Registry عن .dev كما قرأناها في 12 أكتوبر 2026.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.