تيربوريبو في مستودع أحادي حقيقي: ملاحظات ميدانية عن فشل التخزين المؤقت وتسميم متغيرات NEXT_PUBLIC ومفتاح tasks الذي حلّ محل pipeline
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗تيربوريبو أداة لتشغيل المهام تحسب بصمة لمدخلات كل سكربت، وتخزّن مخرجاته، وتعيد تشغيلها بدل تنفيذ العمل من جديد. والتخزين المؤقت صحيح بقدر ما تصرّح به من inputs وoutputs وenv داخل ملف turbo.json، وكل إجابة خاطئة حصلنا عليها منه كانت تصريحاً لم يكتبه أحد.
نقل فريقنا هذا العام مستودعاً أحادياً يضم تطبيقين وستّ حزم إلى تيربوريبو 2: واجهة Next.js، وواجهة برمجية بـ NestJS، وحزم مشتركة للواجهة والإعدادات والأنواع. بدا الأسبوع الأول مكسباً صافياً لأن التكامل المستمر توقف عن إعادة بناء حزم لم يلمسها أحد. وفي الأسبوع الثاني شحنّا بناءً إنتاجياً ما زال يشير إلى واجهة بيئة الاختبار، وكان سجل النشر يقول إن التخزين المؤقت أصاب وأنه يعيد تشغيل السجلات. هذه هي الملاحظات التي كنا نتمنى امتلاكها قبل ذلك النشر.
أهم الخلاصات
تيربوريبو طبقة حفظ نتائج فوق سكربتات package.json. لا يثبّت الاعتماديات ولا يربط مساحات العمل ولا يترجم أي شيء، فذلك عمل pnpm وtsc.
غيّر تيربوريبو 2.0 اسم المفتاح الرئيسي pipeline في turbo.json إلى tasks. شغّل أداة التحويل الرسمية npx @turbo/codemod@latest migrate بدل التعديل اليدوي.
المتغير البيئي غير المصرّح به هو أخطر مدخل للتخزين المؤقت. يدمج Next.js قيم NEXT_PUBLIC داخل حزمة العميل وقت البناء، لذلك قد يعيد التخزين المؤقت روابط بيئة أخرى حرفياً.
يعتمد تيربوريبو 2.0 وضع البيئة الصارم افتراضياً، فلا ترى المهمة إلا المتغيرات المذكورة في env أو globalEnv. هذا الافتراضي يحوّل خطأ القيمة الصامت إلى خطأ صريح بقيمة undefined.
خيار --affected لا يتفوق على تاريخ git لديك. النسخة الضحلة في التكامل المستمر لا تملك أصلاً commit للمقارنة، فيتحوّل الترشيح بصمت إلى بناء كل شيء.
ما الذي يفعله تيربوريبو فعلاً وما الذي لا يحل محله؟
تيربوريبو أداة لتشغيل المهام في المستودعات الأحادية بجافاسكربت، تبني رسماً بيانياً لاعتماديات سكربتاتك، وتحسب بصمة مدخلات كل مهمة، وتستعيد المخرجات المخزّنة عندما تطابق البصمة تشغيلاً سابقاً. هذا كل المنتج. لا يثبّت الحزم، ولا يحل روابط مساحة العمل، ولا يحزم بايتاً واحداً.
توزيع الأدوار يحدد أين تصحّح الأخطاء. مساحات عمل pnpm أو npm أو yarn تثبّت الاعتماديات وتنشئ الروابط الرمزية للحزم الداخلية، وتيربوريبو يقرأ ذلك التعريف ولا ينشئه. أما المترجمات مثل tsc وtsup ومترجم Next.js وواجهة Nest فتقوم بالعمل الفعلي، ويشغّلها تيربوريبو كسكربتات عادية. ولا يقرر تيربوريبو سوى ما يعمل، وبأي ترتيب، وعلى كم نواة، وهل يحتاج للعمل أصلاً.
النموذج الذهني المفيد هو حفظ النتائج. تشغيل turbo run build هو استدعاء محفوظ النتيجة لـ pnpm build في كل حزمة، مفتاحه بصمة لملفات المصدر ورسم الاعتماديات ومدخلات ملف القفل والمتغيرات البيئية المصرّح بها. وحفظ النتائج صحيح فقط عندما تكون الدالة حتمية بالنسبة لوسائطها المصرّح بها. كل خطأ واجهه فريقنا في تيربوريبو كان وسيطاً نسي أحدهم التصريح به.
لماذا يستخدم turbo.json مفتاح tasks بدل pipeline الآن؟
غيّر تيربوريبو 2.0 اسم المفتاح الرئيسي pipeline إلى tasks لأن الاسم القديم كان يوحي بخط أنابيب متسلسل بينما القيمة في الحقيقة مجموعة تعريفات مهام يأتي ترتيبها بالكامل من dependsOn. ملف الإعدادات الذي ما زال يستخدم pipeline يفشل على تيربوريبو 2 بخطأ في المخطط، وأداة التحويل تعيد تسميته مع بقية ترحيلات الإصدار الثاني.
جزءان من الصياغة يحملان معظم المعنى. مدخل dependsOn بصيغة caret-build يعني ابنِ كل حزمة تعتمد عليها هذه الحزمة أولاً، صعوداً في رسم الاعتماديات. ومدخل dependsOn بصيغة build دون علامة caret يعني شغّل مهمة البناء لهذه الحزمة نفسها أولاً. والخلط بينهما ينتج رسماً يبدو صحيحاً لكنه يجدول العمل خطأً.
ضبط persistent على true يشير إلى مهمة لا تنتهي أبداً، مثل خادم التطوير. ويرفض تيربوريبو 2 أن تعلن أي مهمة أخرى اعتماداً على مهمة دائمة، وهو الخطأ الصحيح: مهمة تنتظر شيئاً لا ينتهي هي جمود، لا ترتيب بناء.
لماذا يفشل التخزين المؤقت للبناء لدينا دائماً؟
تفشل مهمة تيربوريبو في إصابة التخزين المؤقت حين يتغير شيء داخل بصمتها، ولا تخزّن شيئاً إطلاقاً حين تكون مصفوفة outputs فارغة. هذان فشلان مختلفان بعرَض واحد وهو إعادة بناء كاملة كل مرة، ويميّز بينهما الأمر turbo run build --dry=json دون تشغيل أي شيء، لأنه يطبع مدخلات كل مهمة ومتغيراتها البيئية وبصمتها النهائية.
ولتشغيل حدث بالفعل، يكتب turbo run build --summarize ملف JSON داخل مجلد .turbo/runs يحتوي تفصيل البصمة لكل مهمة. ومقارنة ملفين منها حول حالة فشل أسرع طريقة نعرفها للعثور على المدخل الوحيد الذي تحرك.
البصمة التي لا تتكرر أبداً تعني عادةً أن ناتجاً مولّداً يُحسب كمصدر. مدخلات تيربوريبو الافتراضية هي ملفات الحزمة المتتبَّعة في git، لذا فإن مجلد dist مرفوعاً إلى المستودع أو ملفاً يحمل ختم البناء يجعل كل تشغيل فريداً، والحل تجاهله في gitignore.
والبصمة التي لا تتغير حين يجب أن تتغير تعني أن مدخلاً حقيقياً غير مرئي. وملف .env.local هو الحالة الكلاسيكية: فهو متجاهَل في git، ولذلك ليس ضمن المدخلات الافتراضية، ولذلك لا يُبطل تعديله شيئاً.
حل الحالة الثانية هو رمز TURBO_DEFAULT داخل مصفوفة inputs، ومعناه المدخلات الافتراضية إضافةً إلى ما تذكره بعدها. أما ذكر .env وحدها فيستبدل الافتراضيات كلياً ويوقف تتبع ملفات مصدرك بصمت، وهو خطأ أسوأ بكثير من الخطأ الذي انطلقت لإصلاحه.
لماذا شحن بناء مخزَّن قيمة NEXT_PUBLIC خاطئة؟
يدمج Next.js كل متغير NEXT_PUBLIC داخل حزمة العميل وقت البناء كنص حرفي، لذلك يحتوي ناتج .next المترجم فعلياً على قيم البيئة التي أنتجته. وإن لم تكن تلك المتغيرات مذكورة في مصفوفة env للمهمة، فلن تراها بصمة تيربوريبو، وسيبدو البناء مطابقاً لبناء سابق، ويستعيد التخزين المؤقت حزمة تشير إلى واجهة برمجية خاطئة. لا شيء يرمي استثناءً، والبناء أخضر.
غيّر تيربوريبو 2.0 وضع البيئة الافتراضي إلى strict، وهذا الافتراضي هو الحل الحقيقي. في الوضع الصارم لا تتلقى عملية المهمة إلا المتغيرات المذكورة في env وglobalEnv، إضافةً إلى قائمة سماح مدمجة صغيرة. لذلك يصبح المتغير غير المصرّح به undefined وقت البناء بدل أن يكون حاضراً بصمت، وهو ما يحوّل خطأ القيمة غير المرئي إلى فشل واضح يلتقطه الفريق محلياً.
نطبّق الآن تفرقتين بشكل آلي. مصفوفة env للمتغيرات التي تغيّر الناتج، فهي تنتمي إلى البصمة. ومصفوفة passThroughEnv للمتغيرات التي تحتاجها المهمة وقت التشغيل ولا يمكنها إثباتاً تغيير الناتج، مثل بيانات اعتماد رفع تُستخدم بعد كتابة الحزمة، فتصل إلى العملية دون الانضمام إلى البصمة. أما globalEnv فللمتغيرات التي تؤثر في كل مهام المستودع، وهي عملياً قائمة قصيرة جداً.
هل تصدّر الحزم الداخلية شيفرة TypeScript أم ناتجاً مترجماً؟
صدّر شيفرة TypeScript الخام من الحزمة الداخلية حين يكون كل مستهلك أداة تحزيم، وترجِمها إلى جافاسكربت حين يكون أي مستهلك عملية Node أو هدف نشر على npm. توثيق تيربوريبو يسمّيهما حزم الترجمة في وقتها وحزم مترجمة، والاختيار يغيّر حجم ما يحتاج إلى تخزين أصلاً.
حزمة الترجمة في وقتها ليس لها خطوة بناء إطلاقاً: حقل exports يشير مباشرةً إلى ./src/index.ts. وعندها يحتاج تطبيق Next.js المستهلك إلى ذكر الحزمة المشتركة في transpilePackages داخل next.config.ts ليتولى مترجمه الشيفرة غير المترجمة. الميزة أنه لا توجد مهمة بناء، فلا يوجد مدخل تخزين مؤقت يجب إبطاله ولا مجلد dist قديم لتصحيحه. والعيب أن كل تطبيق مستهلك يعيد ترجمة الشيفرة المشتركة، وأن خدمة NestJS التي تبدأ بـ node dist/main.js لا تستطيع استيراد ملف .ts إطلاقاً.
أما الحزمة المترجمة فتدفع ثمن خطوة بناء، عادةً tsup أو tsc تُخرج إلى dist مع ملفات التعريف، لتزيل تلك القيود. قاعدتنا بعد التعايش مع الاثنتين: مكونات الواجهة وحزم الأنواع تبقى ترجمة في وقتها، وكل ما تستورده بيئة تشغيل Node أو يُنشر خارج المستودع يُترجم. والفصل على هذا الخط أبقى رسم البناء ضحلاً، لأن معظم الحزم بلا مهمة بناء ولا ينتظر caret-build شيئاً تقريباً.
كيف نبني ما تغيّر فقط في التكامل المستمر؟
تشغيل turbo run build --affected يقصر التشغيل على الحزم التي مُسّت منذ الفرع الأساس، وهو الخيار الذي أضافه تيربوريبو 2.1 ليحل محل صياغة --filter القديمة مع مدى git. يقارن بفرع main افتراضياً، ويتيح المتغيران TURBO_SCM_BASE وTURBO_SCM_HEAD تجاوز طرفي المقارنة، وهو ما تحتاجه بناءات طلبات السحب.
المطبّ الذي كلّف فريقنا بعد ظهيرة كامل هو أن --affected عملية git. خطوة actions/checkout في GitHub Actions تستخدم fetch-depth بقيمة 1 افتراضياً، فلا يملك المشغّل إلا commit واحداً ولا أساس للمقارنة، فيتحول التشغيل إلى بناء كل شيء دون خطأ يفسّر السبب. وضبط fetch-depth على 0 في خطوة السحب يجعل الخيار يعمل كما ينبغي.
التخزين المؤقت البعيد هو ما يجعل كل هذا مهماً على مشغّلات زائلة. فبدونه يبدأ كل عمل تكامل مستمر بتخزين فارغ، والإصابات المحلية التي يستمتع بها المطورون على حواسيبهم لا تفيد أحداً غيرهم. تشغيل turbo login ثم turbo link يربط التخزين البعيد من Vercel، وضبط TURBO_API وTURBO_TOKEN وTURBO_TEAM يوجّه العميل نفسه إلى تخزين مستضاف ذاتياً.
ولدوكر، يكتب turbo prune مع الخيار --docker مجلد out/json يحوي ملف القفل وملفات package.json فقط للرسم الفرعي لذلك التطبيق، إضافةً إلى مجلد out/full يحوي الشيفرة. ونسخ out/json والتثبيت قبل نسخ out/full يعني أن طبقة التثبيت لا تُبطل إلا حين تتغير اعتمادية فعلاً، لا مع كل تعديل في الشيفرة.
الأسئلة الشائعة
س: هل يحل تيربوريبو محل مساحات عمل pnpm؟
ج: لا. يشغّل تيربوريبو المهام ويخزّنها، بينما يظل مدير الحزم هو من يثبّت الاعتماديات ويربط الحزم الداخلية. ويقرأ تيربوريبو تعريف مساحة العمل من pnpm-workspace.yaml أو من حقل workspaces في ملف package.json الجذري.
س: ما الفرق بين تيربوريبو وNx؟
ج: كلاهما يبني رسم مهام ويخزّن المخرجات محلياً وبعيداً. تيربوريبو قائم على الإعدادات فقط ويغلّف سكربتات package.json الموجودة لديك في ملف turbo.json واحد، بينما يضيف Nx إضافات ومولّدات ومنفّذات وأهدافاً مستنتَجة. إن كانت سكربتاتك تعمل أصلاً فتيربوريبو هو الترحيل الأصغر.
س: هل يمكن أن تكون إصابة التخزين المؤقت خاطئة؟
ج: نعم. إصابة التخزين صادقة تماماً بقدر ما صُرّح به في inputs وenv. فإن كان ملف أو متغير يغيّر الناتج ولا يدخل في البصمة، فسيعيد تيربوريبو بثقة ناتجاً قديماً، لذا تحقق من أي إصابة مريبة بـ turbo run build --dry=json.
س: لماذا تحتاج مهمة التطوير إلى persistent بقيمة true؟
ج: خادم التطوير لا ينتهي أبداً، وقيمة persistent الصحيحة تخبر تيربوريبو ألا ينتظره. كما يرفض تيربوريبو 2 أي مهمة تعلن dependsOn على مهمة دائمة، لأن ذلك الاعتماد لا يمكن تحقيقه أبداً.
س: هل يستحق التخزين المؤقت البعيد العناء لفريق صغير؟
ج: للعمل المحلي لا، فالتخزين المحلي تحت node_modules/.cache/turbo يكفي. أما للتكامل المستمر فنعم، لأن حاويات التكامل المستمر زائلة وتبدأ بتخزين فارغ في كل تشغيل، وهو بالضبط الموضع الذي تكلّف فيه إعادة البناء الكاملة أكثر شيء.
قراءات إضافية
أدوات المطوّرين
اختبار دون Jest: كيف نستخدم مشغّل اختبارات Node.js المدمج
يأتي Node.js بمشغّل اختبارات مدمج لا يحتاج اعتماديات ولا إعداداً. كيف يستخدم فريقنا node:test وnode:assert والمحاكاة المدمجة، ويشغّل اختبارات TypeScript دون خطوة بناء، وأين نبقي على Vitest.
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.