Работа с source maps при многоэтапной сборке

При компиляции современного JavaScript-кода исходные файлы редко попадают в браузер в первоначальном виде. Код проходит через несколько этапов обработки:

  • транспиляцию TypeScript в JavaScript;
  • преобразование JSX в обычные вызовы функций;
  • объединение модулей в единый бандл;
  • минификацию;
  • внедрение полифиллов;
  • постобработку сторонними инструментами.

После такой цепочки исходный код существенно отличается от того, который исполняется браузером. Без специальных механизмов отладка становится крайне затруднительной: сообщения об ошибках указывают на строки и позиции внутри итогового бандла, а не исходных файлов проекта.

Для решения этой проблемы используются source maps — специальные карты соответствия между итоговым и исходным кодом.

Esbuild поддерживает генерацию и объединение source maps практически на всех этапах сборки, что особенно важно при многоэтапных пайплайнах.


Принцип работы source maps

Source map представляет собой JSON-документ, который хранит информацию о соответствии между:

  • итоговым файлом;
  • исходными файлами;
  • строками;
  • колонками;
  • именами символов.

Упрощённо карта сообщает браузеру:

данный участок минифицированного кода был получен из определённой строки определённого исходного файла.

Например, имеется исходный TypeScript:

function calculatePrice(price: number) {
    return price * 1.2;
}

console.log(calculatePrice(100));

После минификации код может выглядеть так:

function e(o){return 1.2*o}console.log(e(100));

Без source map ошибка внутри функции будет указывать на позицию внутри строки минифицированного файла.

При наличии карты браузер восстановит информацию и покажет реальное расположение ошибки в TypeScript-файле.


Виды source maps в Esbuild

Esbuild поддерживает несколько режимов генерации карт.

linked

Создаётся отдельный файл карты.

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    sourcemap: true
});

или

sourcemap: 'linked'

Результат:

dist/
 ├─ app.js
 └─ app.js.map

В конец файла добавляется ссылка:

//# sourceMappingURL=app.js.map

Это наиболее распространённый вариант для разработки.


external

Карта создаётся отдельно, но ссылка в файл не добавляется.

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    sourcemap: 'external'
});

Полезно при публикации карт на отдельном сервере.


inline

Карта встраивается прямо в JavaScript-файл.

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    sourcemap: 'inline'
});

Файл содержит большой Base64-блок:

//# sourceMappingURL=dat a:application/json;base64,...

Преимущества:

  • один файл;
  • отсутствие дополнительных запросов.

Недостатки:

  • увеличение размера бандла;
  • неудобство анализа.

both

Комбинированный режим.

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    sourcemap: 'both'
});

Создаются:

  • встроенная карта;
  • отдельный .map-файл.

Используется редко.


Многоэтапная сборка и проблема потери соответствий

Рассмотрим типичную цепочку обработки:

TypeScript
      ↓
Babel
      ↓
Esbuild
      ↓
Minifier

Каждый этап меняет структуру программы.

Если хотя бы один инструмент не передаст информацию о предыдущих преобразованиях, итоговая карта окажется некорректной.

Например:

index.ts
    ↓
index.js + map
    ↓
bundle.js

Если второй этап проигнорирует существующую карту:

index.ts
    ↓
index.js
    ↓
bundle.js

браузер сможет отобразить только промежуточный JavaScript, но не оригинальный TypeScript.

Поэтому в многоступенчатых процессах сборки требуется цепочка source maps.


Объединение source maps в Esbuild

Одной из сильных сторон Esbuild является автоматическое объединение входящих карт.

Допустим, TypeScript-компилятор уже создал карту:

{
  "version": 3,
  ...
}

После этого Esbuild получает:

input.js
input.js.map

Во время сборки Esbuild:

  1. считывает существующую карту;
  2. анализирует собственные преобразования;
  3. строит новую итоговую карту;
  4. объединяет обе карты в одну.

В результате браузер получает прямое соответствие:

bundle.js
    ↓
index.ts

без промежуточных уровней.


Использование TypeScript и Esbuild

Частый сценарий выглядит следующим образом:

{
  "compilerOptions": {
    "sourceMap": true
  }
}

Компиляция:

tsc

Получаем:

src/
 ├─ index.ts
 ├─ index.js
 └─ index.js.map

Далее выполняется сборка через Esbuild:

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    bundle: true,
    sourcemap: true
});

Esbuild автоматически прочитает:

index.js.map

и включит сведения в итоговую карту.


Использование TypeScript напрямую через Esbuild

На практике чаще используется другой вариант.

Esbuild самостоятельно обрабатывает TypeScript:

await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    outfile: 'dist/app.js',
    sourcemap: true
});

В этом случае цепочка преобразований полностью контролируется Esbuild.

Преимущества:

  • более быстрые сборки;
  • отсутствие промежуточных файлов;
  • корректные карты без дополнительной настройки.

Работа с JSX и TSX

При преобразовании JSX возникают серьёзные изменения структуры кода.

Исходник:

export function App() {
    return <h1>Hello</h1>;
}

После компиляции:

export function App() {
    return React.createElement("h1", null, "Hello");
}

Количество строк, колонок и символов меняется.

Esbuild фиксирует эти преобразования в карте:

await esbuild.build({
    entryPoints: ['src/App.tsx'],
    bundle: true,
    sourcemap: true
});

В результате React DevTools и браузерные инструменты продолжают показывать оригинальный TSX-код.


Source maps при минификации

Минификация создаёт наиболее серьёзные различия между исходным и итоговым кодом.

Исходный код:

const calculateTax = (price) => {
    return price * 0.2;
};

После минификации:

const c=e=>.2*e;

Включение карты:

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    minify: true,
    sourcemap: true
});

позволяет браузеру восстанавливать оригинальные:

  • имена функций;
  • номера строк;
  • позиции операторов.

Режимы разработки и production

Обычно применяются разные настройки.

Development

await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    sourcemap: true
});

или

sourcemap: 'inline'

Цель:

  • удобная отладка;
  • просмотр исходников;
  • корректные stack traces.

Production

await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    minify: true,
    sourcemap: 'external'
});

Такой вариант позволяет:

  • не увеличивать размер бандла;
  • хранить карты отдельно;
  • загружать их только при необходимости.

Управление содержимым исходников

По умолчанию карта может содержать полный текст исходных файлов.

Это поле называется:

"sourcesContent"

Пример:

{
  "sourcesContent": [
    "const x = 1;"
  ]
}

Преимущество:

  • браузеру не требуется доступ к исходникам.

Недостаток:

  • увеличение размера карты.

Отключение:

await esbuild.build({
    entryPoints: ['src/index.js'],
    outfile: 'dist/app.js',
    sourcemap: true,
    sourcesContent: false
});

Теперь карта будет хранить только ссылки на файлы.


SourceRoot и структура проекта

В некоторых случаях исходные файлы располагаются отдельно от карты.

Например:

dist/
 ├─ app.js
 └─ app.js.map

src/
 └─ index.ts

Для корректного поиска файлов может использоваться поле:

{
  "sourceRoot": "../src"
}

Esbuild обычно формирует пути автоматически, однако при интеграции с другими инструментами иногда требуется дополнительная настройка структуры каталогов.


Отладка stack traces на сервере

Source maps полезны не только в браузере.

После минификации Node.js может выдавать ошибки вида:

TypeError: Cannot read properties of undefined
    at app.js:1:24567

При наличии карты возможна обратная трансляция:

TypeError: Cannot read properties of undefined
    at services/user.ts:42:15

Для этого используются специальные библиотеки:

npm install source-map-support

Подключение:

import 'source-map-support/register';

Теперь стек вызовов будет отображать реальные исходные файлы.


Генерация карт при использовании API transform()

Метод transform() также поддерживает source maps.

const result = await esbuild.transform(code, {
    loader: 'ts',
    sourcemap: true
});

Результат:

result.code
result.map

Это особенно полезно при построении собственных сборочных пайплайнов.


Передача входящих карт через transform()

Если код уже содержит карту:

//# sourceMappingURL=input.js.map

можно объединять преобразования последовательно.

Схема:

Tool A
    ↓
map A

Tool B
    ↓
map B

Tool C
    ↓
final map

Esbuild поддерживает такие сценарии, сохраняя связь между исходным кодом и конечным результатом.


Проверка корректности итоговой карты

После сложной многоэтапной сборки рекомендуется проверить:

  1. отображаются ли исходные файлы в DevTools;
  2. соответствуют ли номера строк;
  3. открывается ли TypeScript вместо сгенерированного JavaScript;
  4. совпадают ли stack traces с реальными файлами проекта.

Типичный признак ошибки:

bundle.js:1:32567

вместо:

src/components/Button.tsx:18:7

Подобная ситуация почти всегда означает потерю одной из промежуточных карт.


Типичные проблемы при многоэтапной сборке

Отсутствие sourceMap на промежуточном этапе

Например:

babel(input, {
    sourceMaps: false
});

Цепочка обрывается.

В результате Esbuild не сможет восстановить исходное соответствие.


Удаление комментария sourceMappingURL

Некоторые инструменты удаляют:

//# sourceMappingURL=file.js.map

После этого следующая стадия сборки не находит карту.


Неверные относительные пути

Ошибка:

Failed to load source map

часто связана с неправильным расположением файлов:

app.js
app.js.map

и ссылкой:

//# sourceMappingURL=maps/app.js.map

которая указывает на несуществующий путь.


Смешивание inline и external карт

Пример проблемной схемы:

Tool A → inline map
Tool B → external map
Tool C → inline map

Хотя Esbuild способен работать с подобными комбинациями, единый подход обычно обеспечивает более предсказуемый результат.


Практическая схема многоэтапной сборки

Современный процесс может выглядеть следующим образом:

TypeScript
      ↓
Esbuild (TS → JS)
      ↓
Esbuild Bundle
      ↓
Esbuild Minify
      ↓
Source Map

Конфигурация:

await esbuild.build({
    entryPoints: ['src/index.ts'],
    outfile: 'dist/app.js',
    bundle: true,
    minify: true,
    sourcemap: 'external',
    sourcesContent: true
});

Результат:

dist/
 ├─ app.js
 └─ app.js.map

При возникновении ошибки в минифицированном коде браузер или серверная среда смогут восстановить точное местоположение проблемы вплоть до исходного TypeScript-файла, несмотря на множество промежуточных этапов обработки.