عندما تكتب ميزة «نسيت كلمة المرور» في تطبيقك فإن الكود لا يعمل وحده، فهو يرسل بريداً عبر سيرفر 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 من الإعداد كما ذكرنا في قسم الإعداد لكل بيئة، وعلى جهازك يشير هذا الإعداد إلى بديل محلي يتحدث البروتوكول نفسه ولا يصل إلى أحد. والصورة التالية تبين الفكرة:

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

- بديل داخل الكود Mock أو Fake: كلاس يحل محل مرسل البريد في اختبار الوحدة Unit Test ويحفظ الرسالة في قائمة، وهو الأسرع، لكنه لا يختبر SMTP ولا الترميز ولا المرفقات، وقد شرحنا الفرق بين Mock وStub وFake في قسم البدائل في مقال أنواع الاختبارات.
- سيرفر بديل يتحدث البروتوكول نفسه: مثل Mailpit الذي يستقبل SMTP حقيقياً، أو MinIO وSeaweedFS اللذين يتحدثان واجهة S3، أو WireMock الذي يرد على طلبات HTTP كما يرد ال API الخارجي، وهنا يعمل كود الإرسال الحقيقي ومكتبته كما هي، والذي يتغير هو أن الرسالة لا تخرج.
- البرنامج الحقيقي في Container: قاعدة البيانات نفسها بالإصدار نفسه، أو مزود الهوية نفسه مثل Keycloak أو authentik، وهذا هو الخيار الصحيح لكل خدمة تملكها أنت في الإنتاج، فلا يوجد سبب لمحاكاة PostgreSQL وأنت تستطيع تشغيله.
- بيئة 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. والصورة التالية تلخص ذلك:

تجربة عملية: الخادم نفسه بسبعة عناوين
الآن سوف نختبر الكلام السابق بأنفسنا، فنكتب خادماً صغيراً بلغة 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 في مقال الشهادات الرقمية.
المخاطر: ما الذي قد يخرج من جهازك؟
بيئة التطوير المحلية تبدو مكاناً لا يحتاج حماية لأنها على جهازك، لكن هناك أربعة أخطاء تتكرر، وكل منها يخرج شيئاً من جهازك إلى العالم:
- بريد حقيقي من بيئة التطوير: كما في قصة HBO Max، والحماية هنا طبقتان: أن يرفض التطبيق نفسه أن يعمل في وضع التطوير بإعداد SMTP غير صندوق الالتقاط، وأن تكون الشبكة التي يعمل عليها لا تصل إلى الإنترنت أصلاً، وسوف نبني الطبقتين ونكسرهما في الجزء الثالث.
- مفاتيح حقيقية في ملف
.env: ملف الإعداد المحلي يجب ألا يحتوي إلا قيماً محلية، ومفاتيح Sandbox عند الحاجة، ولا يرفع إلى Git، وإذا رفع مفتاح بالخطأ فاعتبره مكشوفاً وغيره فوراً، والسبب أن حذفه من آخر Commit لا يحذفه من التاريخ. - جهة إصدار محلية تثق بها: المفتاح الخاص لجهة الإصدار المحلية يستطيع أن يصدر شهادة لأي موقع، مثل بنكك أو بريدك، وسوف يقبلها متصفحك، ولهذا يحذر توثيق mkcert من أن الملف
rootCA-key.pemيعطي من يملكه قدرة كاملة على اعتراض الاتصالات الآمنة من جهازك، فلا تشاركه ولا تضعه في مستودع الفريق، ولكل مطور جهة إصدار خاصة به. - خادم التطوير مفتوح للشبكة: خادم التطوير الذي يستمع على
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.