فهرست مطالب
خطایی از production رسیده و stack trace آن چنین است: TypeError: Cannot read properties of undefined (reading 'id') at e.t (index-4f2a9c.js:1:48213). خط ۱، ستون ۴۸۲۱۳ از فایلی که bundler همهٔ کد شما را در آن فشرده کرده است. بدون source map این اطلاعات تقریباً بیفایده است. با source map همین خط به src/cart/CartSummary.tsx:42 و نام تابع واقعی تبدیل میشود. اما اگر source map را همانطور که bundler بهطور پیشفرض میسازد کنار فایلهای سایت منتشر کنید، هر کسی با باز کردن DevTools کل کد منبع شما را، با کامنتها و نام متغیرها، میبیند.
این نوشته دربارهٔ راه میانه است: ساختن source map در هر build، نگه داشتنش در جایی که فقط تیم شما به آن دسترسی دارد، و استفاده از آن برای خوانا کردن stack trace هایی که از کاربران واقعی میرسد.
source map چیست و چطور کار میکند
source map یک فایل JSON است (معمولاً با پسوند .map) که برای هر بخش از کد خروجی، میگوید در کدام فایل منبع، کدام خط و کدام ستون بوده است. فیلدهای اصلی آن sources (فهرست فایلهای منبع)، names (نامهای اصلی متغیرها و توابع)، mappings (نگاشت فشرده با کدگذاری VLQ) و در صورت فعال بودن، sourcesContent است که متن کامل فایلهای منبع را در خود دارد.
مرورگر از دو راه میفهمد source map کجاست: کامنت //# sourceMappingURL=index-4f2a9c.js.map در انتهای فایل جاوااسکریپت، یا هدر HTTP به نام SourceMap. DevTools فقط وقتی باز است این فایل را دانلود میکند، پس source map روی سرعت بارگذاری صفحه برای کاربر عادی اثری ندارد. مشکل حجم نیست؛ مشکل این است که فایل در دسترس عموم است.
سه رویکرد و هزینهٔ هرکدام
| رویکرد | stack trace | کد منبع | توضیح |
|---|---|---|---|
| بدون source map | ناخوانا | محفوظ | عیبیابی خطای production تقریباً حدس است |
| source map عمومی | خوانا | عمومی | پیشفرض بسیاری از قالبها؛ کد و کامنتها برای همه قابل خواندن |
| hidden source map خصوصی | خوانا برای تیم | محفوظ | فایل ساخته میشود ولی منتشر نمیشود یا دسترسیاش محدود است |
باید صریح بود: minify کردن کد امنیت نیست. هر کسی میتواند کد minify شده را با ابزارهای قالببندی تا حدی خوانا کند. اما فاصلهٔ «کد فشردهٔ بدون نام» تا «کد منبع کامل با کامنتها، نام فایلها و ساختار پوشهها» زیاد است. کامنتهایی مثل «این endpoint هنوز احراز هویت ندارد» یا آدرس سرویسهای داخلی نباید در اختیار هر بازدیدکنندهای باشد. و البته هیچ رازی مثل کلید API نباید در کد سمت مرورگر باشد، چه source map منتشر شود چه نه.
ساختن hidden source map در Vite و webpack
«hidden» یعنی فایل .map ساخته میشود ولی کامنت sourceMappingURL به فایل جاوااسکریپت اضافه نمیشود. مرورگر از وجود آن خبر ندارد، ولی شما آن را برای ترجمهٔ stack trace در اختیار دارید.
در Vite:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
sourcemap: 'hidden',
},
});
در webpack:
// webpack.config.js
module.exports = {
mode: 'production',
devtool: 'hidden-source-map',
// ...
};
در Angular CLI گزینهٔ sourceMap در angular.json شیء { "scripts": true, "hidden": true } را میپذیرد، و در Next.js بهطور پیشفرض source map سمت مرورگر در production ساخته نمیشود مگر productionBrowserSourceMaps را فعال کنید. در هر ابزاری که استفاده میکنید، بعد از build پوشهٔ خروجی را بررسی کنید و مطمئن شوید هم فایلهای .map ساخته شدهاند و هم کامنت sourceMappingURL در فایلهای .js نیست:
ls dist/assets/*.map
grep -l "sourceMappingURL" dist/assets/*.js # باید خروجی خالی باشد
source map را کجا نگه داریم
سه گزینهٔ رایج وجود دارد و انتخاب به زیرساخت شما بستگی دارد:
- جدا کردن از خروجی در CI: بعد از build، فایلهای
.mapرا به یک مخزن خصوصی (artifact storage در CI، یک باکت خصوصی، یا پوشهای روی سرور داخلی) منتقل کنید و از پوشهٔ deploy حذفشان کنید. سادهترین و امنترین روش است. - منتشر کردن با دسترسی محدود: فایلها روی همان سرور میمانند ولی وبسرور فقط به آدرسهای شبکهٔ داخلی یا VPN اجازهٔ دریافتشان را میدهد.
- بارگذاری در ابزار رصد خطا: اگر ابزاری که استفاده میکنید بارگذاری source map را پشتیبانی میکند، فایلها در CI به آن ارسال و از خروجی حذف میشوند.
نمونهٔ گام CI برای گزینهٔ اول، که فایلها را بر اساس نسخهٔ انتشار بایگانی و سپس حذف میکند:
RELEASE="v$(node -p "require('./package.json').version")-$(git rev-parse --short HEAD)"
mkdir -p "sourcemaps/$RELEASE"
find dist -name '*.map' -exec cp --parents {} "sourcemaps/$RELEASE/" ;
find dist -name '*.map' -delete
tar -czf "sourcemaps-$RELEASE.tar.gz" "sourcemaps/$RELEASE"
و برای گزینهٔ دوم در nginx:
location ~* .map$ {
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
}
ترجمهٔ stack trace با source map
وقتی source map خصوصی دارید، ترجمهٔ یک موقعیت از کد فشرده به کد منبع با کتابخانهٔ source-map چند خط است. اسکریپت زیر با نسخهٔ ۰.۷ این کتابخانه کار میکند:
// resolve.mjs — node resolve.mjs dist/assets/index-4f2a9c.js.map 1 48213
import { readFile } from 'node:fs/promises';
import { SourceMapConsumer } from 'source-map';
const [mapPath, line, column] = process.argv.slice(2);
const raw = JSON.parse(await readFile(mapPath, 'utf8'));
await SourceMapConsumer.with(raw, null, (consumer) => {
const pos = consumer.originalPositionFor({
line: Number(line), // خط: از ۱ شمرده میشود
column: Number(column) - 1, // ستون: در این کتابخانه از ۰ شمرده میشود
});
console.log(pos); // { source, line, column, name }
});
به تفاوت شمارش ستون دقت کنید. stack trace مرورگرهای مبتنی بر Chromium ستون را از ۱ میشمارند، ولی originalPositionFor ستون را از صفر انتظار دارد. اختلاف یک ستون در کد فشردهای که همهچیز در یک خط است، ممکن است شما را به تابع اشتباه ببرد. اگر نتیجه بیمعنی به نظر رسید، اول همین را بررسی کنید.
قالب stack trace در مرورگرهای مختلف
ترجمهٔ یک موقعیت ساده است؛ ترجمهٔ کل stack trace یعنی اول باید آن را تجزیه کنید، و هر موتور جاوااسکریپت قالب خودش را دارد. Chrome و Edge و دیگر مرورگرهای مبتنی بر V8 هر قاب را به شکل at functionName (https://example.com/assets/index.js:1:48213) مینویسند. Firefox و Safari قالب functionName@https://example.com/assets/index.js:1:48213 را به کار میبرند و گاهی نام تابع را خالی میگذارند. در Safari قابهای مربوط به کد بومی یا eval هم شکل دیگری دارند.
به همین دلیل بهتر است تجزیه را خودتان با regex ننویسید و از کتابخانههای آزمودهای مثل error-stack-parser یا stacktrace-parser استفاده کنید که هر قاب را به فیلدهای فایل، خط، ستون و نام تابع تبدیل میکنند. بعد از تجزیه، برای هر قاب فایل .map متناظر را از روی نام فایل پیدا کنید و موقعیت را ترجمه کنید. قابهایی که فایلشان map ندارد را دستنخورده نگه دارید تا ترتیب stack به هم نخورد.
نکتهٔ دیگر اینکه نام فایلهای خروجی bundler معمولاً hash محتوا دارند، مثل index-4f2a9c.js. این hash به شما کمک میکند حتی بدون شناسهٔ نسخه هم بفهمید کدام فایل map مربوط به کدام build است؛ ولی بایگانی بر اساس نسخهٔ انتشار، جستجو را سادهتر و قابل اعتمادتر میکند.
نسخهٔ انتشار: حلقهٔ گمشده
source map فقط برای همان build که از آن ساخته شده درست است. اگر خطا از نسخهٔ دیروز آمده و شما با source map امروز ترجمهاش کنید، به خط اشتباهی میرسید و ممکن است ساعتها دنبال باگی بگردید که در آن خط نیست. به همین دلیل هر رویداد خطا باید شناسهٔ نسخهٔ انتشار را همراه داشته باشد و هر بایگانی source map هم با همان شناسه ذخیره شود.
- شناسهٔ نسخه را در زمان build تزریق کنید، مثلاً با متغیر محیطی
VITE_RELEASEیاDefinePluginدر webpack. - همان شناسه را به ابزار رصد خطا بدهید تا روی هر رویداد ثبت شود.
- بایگانی source map ها را حداقل به اندازهٔ دورهٔ نگهداری دادههای خطا نگه دارید. اگر خطاها ۹۰ روز نگه داشته میشوند، source map سه ماه پیش را هم لازم دارید.
- build را تکرارپذیر کنید. اگر دو build از یک commit خروجی متفاوت دارند (مثلاً بهخاطر زمان build در نام فایل)، source map یکی برای دیگری درست نیست.
اشتباههای رایج
- source map کتابخانههای شخص ثالث: اگر فقط کد خودتان map دارد و vendor bundle ندارد، خطاهای درون کتابخانهها همچنان ناخوانا میمانند. معمولاً این مشکلی نیست، چون باگ در کد شماست، ولی بدانید چرا آن قابهای stack ترجمه نمیشوند.
- CDN و کش: اگر فایلهای
.mapیک بار عمومی منتشر شدهاند، حذفشان از سرور کافی نیست؛ ممکن است در کش CDN مانده باشند. کش را پاک کنید. - sourcesContent: اگر نمیخواهید متن منبع حتی در بایگانی خصوصی باشد، میتوانید آن را حذف کنید و ترجمه را کنار مخزن کد انجام دهید. ولی ترجمه سختتر میشود و برای بیشتر تیمها لازم نیست.
- اعتماد به محرمانگی: hidden source map از لو رفتن ساختار کد جلوگیری میکند، نه از مهندسی معکوس. منطق حساس را سمت سرور نگه دارید.
رصد خطا با نسخه در باگماگ
باگماگ با یک تگ اسکریپت خطاهای جاوااسکریپت، Promise های رد شده و درخواستهای ناموفق شبکه را با stack trace کامل ثبت میکند و در BugMug.init فیلد release را میپذیرد. همین فیلد کمک میکند هر رویداد را به build دقیقی که آن را تولید کرده وصل کنید و stack trace را با source map همان نسخه از بایگانی خصوصی خودتان ترجمه کنید. breadcrumb ها (کلیکها، ناوبری و لاگهای پیش از خطا) و گروهبندی خطاهای مشابه با fingerprint هم کنار هر رویداد هست، تا بدانید یک خطا چند کاربر را درگیر کرده است.
<script src="https://app.bugmug.ir/bugmug.js"></script>
<script>
BugMug.init({ publicKey: 'pk_xxx', release: 'v2.4.1-a1b2c3d' });
</script>
برای آشنایی با کل فرایند، راهنمای رصد خطای جاوااسکریپت و چرخهٔ عمر خطای نرمافزار را ببینید. اگر کاربران خودشان هم باگ گزارش میدهند، گزارش باگ با اسکرینشات مکمل همین بحث است.
پرسشهای پرتکرار
آیا source map سرعت سایت را کم میکند؟
نه. مرورگر فقط وقتی DevTools باز است فایل .map را دانلود میکند. تنها هزینه، زمان build و فضای ذخیرهسازی است. دلیل منتشر نکردن source map محرمانگی کد است، نه کارایی.
source map سمت سرور (Node.js) هم لازم است؟
اگر کد سرور را با TypeScript یا bundler میسازید، بله. Node.js با گزینهٔ --enable-source-maps خودش stack trace را با source map ترجمه میکند. چون این فایلها روی سرور میمانند و عمومی نمیشوند، نگرانی محرمانگی سمت مرورگر را ندارند.
اگر source map یک نسخهٔ قدیمی گم شده باشد چه؟
اگر build تکرارپذیر است، میتوانید همان commit را دوباره build کنید و source map را بازسازی کنید. اگر نیست، باید با کد فشرده و قالببندی دستی کار کنید. به همین دلیل بایگانی source map با شناسهٔ نسخه، بخشی از فرایند انتشار است نه یک کار اختیاری.
جمعبندی
source map فاصلهٔ «یک خطا در خط ۱ ستون ۴۸۲۱۳» تا «خط ۴۲ از CartSummary» را پر میکند. لازم نیست برای داشتنش کد منبع را عمومی کنید: آن را بهصورت hidden بسازید، در CI از خروجی جدا و با شناسهٔ نسخه بایگانی کنید، و همان شناسه را روی هر رویداد خطا ثبت کنید. با این سه کار، stack trace production برای تیم شما خوانا و برای بقیه همان کد فشرده میماند. و فراموش نکنید که هیچکدام از اینها جای نگه داشتن رازها و منطق حساس در سمت سرور را نمیگیرد.