Асинхронные плагины

Модель выполнения плагинов и ограничения синхронного API

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

Плагины в этой системе являются точками расширения, но их выполнение подчинено тем же ограничениям. Внутри ядра esbuild нет полноценного event loop, аналогичного Node.js, поэтому асинхронность реализуется через ограниченные механизмы: возврат промисов в хуках и ожидание их завершения до продолжения пайплайна.

Ключевое ограничение заключается в том, что асинхронные операции допустимы только в рамках определённых стадий плагинного API. Любая попытка внедрить произвольный неблокирующий код вне этих стадий приводит к невозможности корректного завершения сборки.


Архитектура асинхронных хуков

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

Типовой плагин определяется через функцию setup, внутри которой регистрируются обработчики событий:

  • onResolve
  • onLoad
  • onStart
  • onEnd

Каждый из этих хуков может быть асинхронным, но наиболее часто асинхронность используется в onResolve и onLoad, так как именно они участвуют в разрешении модулей и загрузке содержимого файлов.

Асинхронность реализуется не через отдельный поток выполнения, а через ожидание завершения промисов внутри общего пайплайна сборки.


Асинхронный onResolve

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

Пример структуры:

const asyncResolvePlugin = {
  name: 'async-resolve',
  setup(build) {
    build.onResolve({ filter: /^virtual:/ }, async (args) => {
      const data = await fetchMetadata(args.path);

      return {
        path: data.realPath,
        namespace: 'virtual-ns'
      };
    });
  }
};

В этом примере важно, что возвращается Promise, а не объект напрямую. Esbuild приостанавливает процесс резолва до получения результата.

Особенность заключается в том, что асинхронный onResolve может существенно влиять на производительность всей сборки. Даже один медленный промис увеличивает latency всей цепочки модулей, поскольку резолвинг имеет древовидную структуру зависимостей.


Асинхронный onLoad

onLoad используется для получения содержимого модуля после его резолва. Это один из самых частых сценариев применения асинхронности, особенно при интеграции с удалёнными источниками или динамической генерацией кода.

const asyncLoadPlugin = {
  name: 'async-load',
  setup(build) {
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      const content = await readRemoteFile(args.path);

      return {
        contents: content,
        loader: 'text'
      };
    });
  }
};

Здесь асинхронная операция может включать:

  • чтение файлов через нестандартные API
  • загрузку данных по HTTP
  • обращение к кеш-системам
  • генерацию кода на лету

Важно, что результат должен быть приведён к структуре, ожидаемой esbuild: contents и loader. Любые задержки в этом этапе напрямую увеличивают время построения графа модулей.


Влияние асинхронности на граф зависимостей

Асинхронные плагины влияют на построение dependency graph, поскольку каждый await в onResolve или onLoad может задерживать раскрытие ветвей графа.

Граф модулей в esbuild строится лениво: модуль не загружается, пока не завершено разрешение его зависимостей. Это означает, что асинхронность может привести к каскадным задержкам.

Характерная проблема:

  1. onResolve делает запрос к внешнему API
  2. API отвечает с задержкой
  3. блокируется загрузка модуля
  4. блокируются все его зависимости
  5. часть графа простаивает

Для минимизации эффекта применяются техники:

  • кеширование результатов асинхронных операций
  • агрегация запросов
  • предварительная загрузка метаданных
  • отказ от сетевых вызовов в критическом пути сборки

Параллелизм и очередь выполнения

Несмотря на синхронную модель ядра, асинхронные плагины могут выполняться параллельно на уровне промисов. Esbuild запускает несколько хуков одновременно, но синхронизирует результат перед переходом к следующей стадии.

Важно различать:

  • параллельное выполнение промисов внутри одного этапа
  • последовательность этапов сборки

Например, несколько onLoad могут выполняться одновременно, если они не зависят друг от друга. Однако переход к следующему этапу невозможен до завершения всех активных промисов текущего этапа.


Обработка ошибок в асинхронных плагинах

Ошибки в промисах обрабатываются стандартным механизмом отклонения (Promise.reject). Esbuild перехватывает такие ошибки и прерывает сборку.

build.onLoad({ filter: /\.data$/ }, async (args) => {
  const data = await loadData(args.path);

  if (!data) {
    throw new Error(`Не удалось загрузить ${args.path}`);
  }

  return {
    contents: JSON.stringify(data),
    loader: 'json'
  };
});

Поведение при ошибке:

  • сборка немедленно останавливается
  • ошибка агрегируется в общий отчёт
  • стек вызовов может быть ограничен из-за изоляции Go-рантайма

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


Ограничения асинхронных операций

Несмотря на гибкость, асинхронные плагины в esbuild имеют ряд ограничений:

  • отсутствие полноценного доступа к event loop Node.js
  • невозможность долгоживущих фоновых задач внутри плагина
  • отсутствие shared state между инстансами сборки
  • ограниченная сериализация данных между этапами

Асинхронность существует только как механизм ожидания результата, но не как полноценная модель конкурентного исполнения.


Кэширование в асинхронных плагинах

Кэширование является ключевым инструментом оптимизации, так как каждый await в цепочке влияет на общую производительность.

Типовые стратегии:

Локальный in-memory cache

const cache = new Map();

build.onLoad({ filter: /\.api$/ }, async (args) => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }

  const result = await fetchAPI(args.path);

  const output = {
    contents: result,
    loader: 'json'
  };

  cache.set(args.path, output);
  return output;
});

Дедупликация запросов

Если несколько модулей запрашивают один и тот же ресурс, используется shared promise:

const inflight = new Map();

function getData(path) {
  if (inflight.has(path)) return inflight.get(path);

  const promise = fetchData(path).finally(() => {
    inflight.delete(path);
  });

  inflight.set(path, promise);
  return promise;
}

Асинхронные плагины и виртуальные модули

Асинхронность особенно полезна при создании виртуальных модулей, где содержимое генерируется динамически:

  • конфигурации окружения
  • API-обёртки
  • сборка метаданных
  • компиляция шаблонов

Такие модули часто не существуют в файловой системе, и их содержимое формируется на основе внешних источников.

build.onResolve({ filter: /^config:/ }, (args) => {
  return {
    path: args.path,
    namespace: 'config-ns'
  };
});

build.onLoad({ filter: /.*/, namespace: 'config-ns' }, async (args) => {
  const config = await loadConfig();

  return {
    contents: JSON.stringify(config),
    loader: 'json'
  };
});

Производственные практики использования асинхронности

В реальных сборочных системах асинхронные плагины применяются ограниченно и только в некритичных частях пайплайна. Основные рекомендации:

  • избегать сетевых запросов в onResolve
  • минимизировать задержки в onLoad
  • переносить тяжёлые вычисления в предварительные шаги
  • использовать кеширование как обязательный слой
  • разделять плагины на быстрые и медленные по логике выполнения

Асинхронность в контексте esbuild рассматривается как инструмент интеграции, а не как основной механизм вычислений.


Совместимость с экосистемой Node.js

Асинхронные плагины часто используются для интеграции с Node.js API:

  • файловые операции
  • HTTP-клиенты
  • базы данных
  • системные утилиты

Однако важно учитывать, что esbuild не эмулирует полноценную Node.js среду. Любая зависимость от специфичных runtime-особенностей может привести к несовместимости при сборке в разных окружениях.

Асинхронность здесь выступает связующим слоем между ограниченным API сборщика и внешними системами.