یک مخزن، چند پروژه و یک مرز مشترک
فرض کنید سایت عمومی و پنل داخلی یک تیم از formatter، tokenهای طراحی و چند تابع دامنه استفاده میکنند. کپی کردن این فایلها در دو مخزن، اصلاح مشترک را به دو تغییر جدا تبدیل میکند. Monorepo میتواند این پروژهها و packageهای مشترک را کنار هم نگه دارد تا تغییر مرتبط در یک commit ثبت شود. این انتخاب به معنی یکی شدن همهٔ برنامهها نیست؛ هر برنامه هنوز نام، وابستگی و خروجی خودش را دارد. مرز packageها باید از ابتدا قابل خواندن باشد.
Turborepo اجرای taskهای همین مجموعه را بر اساس گراف وابستگی و تنظیم cache هماهنگ میکند. خودش compiler، package manager یا سامانهٔ استقرار عمومی نیست. npm، pnpm یا ابزار دیگر همچنان نصب و workspace را مدیریت میکنند؛ برنامهٔ build هر package هم ابزار خودش را دارد. تفاوت این مسئولیتها مهم است، چون اجرای سریع یک task اشتباه، خروجی درست تولید نمیکند. ابتدا باید هر پروژه مستقل قرارداد ساخت روشن داشته باشد و سپس هماهنگی میان آنها تعریف شود.
اشتراک کد را با وابستگی واقعی بیان کنید
اگر برنامه از package مشترک استفاده میکند، نام آن package باید در dependency برنامه ثبت شود. import نسبی عمیق از پوشهٔ یک پروژهٔ دیگر، گراف را دور میزند و جابهجایی فایل را پرهزینه میکند. package مشترک هم بهتر است فقط چیزهایی را export کند که قرارداد عمومی آن است. فایل داخلی helper نباید اتفاقی API مصرفکنندگان شود. راهنمای ساختار مخزن دربارهٔ workspaceها و مرز packageها توضیح داده است.
اشتراک بیشازحد هم هزینه دارد. اگر دو صفحه فقط امروز متن یکسان دارند، الزاماً به package مشترک احتیاج ندارند. کدی که سیاست متفاوت دو محصول را به یک شرط بزرگ تبدیل میکند، ممکن است تغییر مستقل را سختتر کند. بهتر است بخش پایدار و واقعاً مشترک جدا شود؛ مثلاً فرمت شماره یا token طراحی. وابستگی چرخهای میان packageها نشانهای است که مرز مسئولیت نیاز به بازبینی دارد. گراف باید بتواند ترتیب کارها را بدون حدس توضیح دهد.
Task به نام scriptها وصل میشود
در تنظیمات فعلی Turborepo از بخش tasks استفاده میشود. کلید build با script همنام در packageها ارتباط دارد. dependsOn با مقدار ^build میگوید پیش از build این package، task ساخت dependencyهای workspace آن آماده شود. این کار با اجرای build همهٔ پوشهها در یک ترتیب دستی متفاوت است. برنامه باید dependency واقعی داشته باشد تا ارتباط درست ساخته شود. مستندات تنظیم task قرارداد وابستگیها، outputs و taskهای طولانی را توضیح میدهد.
هر خروجی که باید از cache برگردد، در outputs ثبت میشود. اگر task فایل dist میسازد اما خروجی اعلام نشده، بازگشت log به تنهایی فایل مورد نیاز برنامه را فراهم نمیکند. task بررسی syntax ممکن است خروجی فایلی نداشته باشد و outputs خالی مناسب باشد. Task توسعه که مدام فعال میماند، با build پایانپذیر یکسان نیست؛ تنظیم persistent و cache آن باید مطابق رفتار واقعی باشد. نمونهٔ این نوشته فقط taskهای پایانپذیر دارد تا ترتیب ساده بماند.
نمونهٔ کامل با یک package و یک سایت
ساختار نمونه سه package.json دارد: یکی در ریشه، یکی در packages/shared و یکی در apps/web. Node 24 و npm 11.6.0 برای دنبال کردن همین قرارداد نمونه لازماند. عدد packageManager نسخهٔ ابزار این مثال است و ادعای جدیدترین نسخه ندارد. اگر نسخهٔ متفاوتی استفاده میکنید، مقدار ثبتشده و lock را هماهنگ کنید. در ریشه package.json و turbo.json، در packages/shared فایلهای package.json و build.mjs و src/index.js، و در apps/web فایلهای package.json و build.mjs را قرار دهید.
{"name":"workspace-demo","private":true,"version":"1.0.0","packageManager":"npm@11.6.0","workspaces":["apps/*","packages/*"],"scripts":{"build":"turbo run build","check":"turbo run check"},"devDependencies":{"turbo":"^2.0.0"}}
{"$schema":"https://turborepo.dev/schema.json","tasks":{"build":{"dependsOn":["^build"],"outputs":["dist/**"]},"check":{"outputs":[]}}}
{"name":"@demo/shared","version":"1.0.0","private":true,"type":"module","exports":{".":"./dist/index.js"},"scripts":{"build":"node build.mjs","check":"node --check src/index.js"}}
export function heading(name) {
return `سلام ${name}`;
}
import { mkdir, copyFile } from 'node:fs/promises';
await mkdir('dist', { recursive: true });
await copyFile('src/index.js', 'dist/index.js');
{"name":"@demo/web","version":"1.0.0","private":true,"type":"module","dependencies":{"@demo/shared":"1.0.0"},"scripts":{"build":"node build.mjs","check":"node --check build.mjs"}}
import { mkdir, writeFile } from 'node:fs/promises';
import { heading } from '@demo/shared';
const escape = (text) => text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
const html = `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Workspace</title><style>body{font-family:system-ui,sans-serif;background:#edf3f4;color:#19303a;padding:2rem}main{max-width:36rem;margin:auto;background:white;padding:2rem;border-radius:1rem}</style></head><body><main><h1>${escape(heading('همکار'))}</h1><p>این صفحه از package مشترک ساخته شده است.</p></main></body></html>`;
await mkdir('dist', { recursive: true });
await writeFile('dist/index.html', html, 'utf8');
در ریشه npm install را اجرا کنید؛ npm workspace محلی با نام @demo/shared و نسخهٔ متناظر را پیوند میدهد و lock واقعی میسازد. سپس npm run build را اجرا کنید. shared ابتدا فایل src را به dist میبرد، بعد web تابع را از export package میخواند و HTML میسازد. apps/web/dist/index.html را باز کنید؛ خروجی استاتیک به server یا API احتیاج ندارد. npm run check فقط syntax فایلهای مشخص را بررسی میکند و معادل تست رفتار دامنه نیست. این دستورها روش اجرای نمونهاند و در این مقاله گزارشی از اجرای آزمایش نداریم.
برای نامهای مقصد، فایلهای نمونه را مطابق ساختار گفتهشده تغییر نام دهید. CSS لازم داخل HTML تولیدشده است و markup متناظر دارد. تابع escape محتوای عنوان را پیش از قرار دادن در HTML تبدیل میکند؛ چون نمونه نام ثابت دارد، این بخش ساده است، اما مسیر تولید HTML باید از ابتدا با مرز متن آشنا باشد. در پروژهٔ واقعی template engine و قرارداد داده را متناسب با نیاز انتخاب کنید. نمونه فقط وابستگی و ترتیب task را نشان میدهد و framework رابط را تحمیل نمیکند.
Cache نتیجهٔ تابعی با ورودی مشخص است
Cache زمانی قابل اعتماد است که خروجی task به ورودیهای شناختهشده وابسته باشد. تغییر فایل منبع، lock و تنظیم task باید نتیجهٔ مناسب تولید کند. اگر script از زمان فعلی، سرویس بیرونی یا فایلی بیرون از محدوده استفاده کند، ممکن است خروجی با همان ورودی ظاهری متفاوت شود. راهنمای cache دربارهٔ hash و بازگرداندن خروجی توضیح میدهد. Cache اشتباه میتواند خطا را پنهان کند؛ cache hit به معنی تازه بودن همهٔ اطلاعات بیرونی نیست.
در مثال، shared از فایل محلی و web از package مشترک استفاده میکند. تغییر در منبع shared باید در مسیر ساخت مصرفکننده هم لحاظ شود. اگر خروجی build شامل timestamp بیدلیل باشد، مقایسه و تکرارپذیری سختتر میشود. عملیات انتشار، ارسال پیام یا تغییر پایگاه داده را نباید مانند تولید فایل قابل cache دید. اجرای دوباره یا رد شدن یک عملیات دارای side effect نتیجهٔ متفاوتی دارد. برای این کارها task مناسب با cache غیرفعال و قرارداد انتشار جدا لازم است.
متغیر محیطی هم ورودی است
اگر URL عمومی API در build مصرف میشود، دو محیط با مقدار متفاوت میتوانند فایل خروجی متفاوت بسازند. بنابراین باید نام متغیرهای اثرگذار در تنظیم task یا قرارداد سراسری قرار گیرد. نادیده گرفتن این تفاوت میتواند فایل ساختهشده برای محیط دیگری را از cache برگرداند. راهنمای environment variable تفاوت متغیرهای وارد hash و متغیرهای صرفاً عبوری را توضیح میدهد. نمونهٔ فعلی ورودی محیطی ندارد تا این مسئولیت پشت کد پنهان نشود.
فایل محیطی و secret را با ورودی عمومی build یکی نگیرید. هر مقداری که وارد bundle مرورگر شد، خصوصی فرض نمیشود. اطلاعات محرمانه میتواند در log، artifact یا خطای ابزار هم نشت کند؛ حتی وقتی فایل اصلی در مخزن نیست. مقالهٔ Docker برای فرانتاند همین مرز را در مرحلهٔ ساخت بررسی میکند. تعریف task باید روشن کند چه چیزی ورودی عمومی خروجی است و چه چیزی فقط برای دسترسی موقت ابزار استفاده میشود.
Remote cache نیاز به قرارداد دسترسی دارد
Remote cache امکان اشتراک نتیجهٔ task میان دستگاهها و CI را فراهم میکند. این قابلیت صرفاً با همپوشانی مخزن به اشتراک امن تبدیل نمیشود. مجوز خواندن و نوشتن، اطلاعات داخل log و artifact و اعتماد به خروجی ذخیرهشده باید مشخص شوند. مستندات remote caching گزینههای اتصال و حفاظت از artifact را بیان میکند. در نمونهٔ محلی نیازی به حساب یا token نداریم و تنظیم اتصال اضافه نشده است.
انتشار مستقل با مخزن مشترک امکانپذیر است
یک commit میتواند تغییر package و مصرفکننده را با هم ثبت کند، اما به معنی انتشار همزمان همهٔ محصولات نیست. هر برنامه مقصد، تنظیم محیط و نسخهٔ خروجی خودش را میخواهد. اگر package عمومی منتشر میکنید، نسخه و changelog آن مسئولیت جدا دارد. در package خصوصی، تغییر قرارداد باز هم باید همهٔ مصرفکنندگان را پوشش دهد. فیلتر کردن taskها کمک میکند کار لازم محدود شود، ولی بررسی اثر تغییر shared نباید فقط به نام پوشهٔ تغییرکرده تکیه کند.
برای تیمی با یک برنامهٔ کوچک، Monorepo ممکن است هنوز ارزش هزینهٔ تنظیم را نداشته باشد. وقتی چند محصول واقعاً کد و چرخهٔ تغییر مشترک دارند، ثبت وابستگی و outputs اهمیت پیدا میکند. نوشتهٔ مهاجرت App Router نمونهای از تغییر قرارداد برنامه است که بهتر است در محدودهٔ مشخص انجام شود. هدف این ساختار، قابل دنبال کردن شدن مسیر تغییر از package مشترک تا خروجی هر محصول است؛ سرعت اجرای task بعد از روشن شدن همین مسیر معنا پیدا میکند.
هنوز دیدگاهی ثبت نشده
اولین نفری باشید که نظر میدهد!