Плагин для загрузки данных из внешних источников

В системе сборки на основе Esbuild плагины позволяют расширять стандартный процесс обработки модулей и внедрять произвольную логику получения и трансформации ресурсов. Одним из распространённых сценариев становится загрузка данных из внешних источников — HTTP API, удалённых файловых хранилищ, динамических конфигураций или сервисов контента.

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


Базовая структура плагина

Плагин Esbuild представляет собой объект с методом setup, который получает контекст сборщика. Через него регистрируются обработчики:

export const externalDataPlugin = () => ({
  name: "external-data-plugin",
  setup(build) {
    // onResolve и onLoad регистрируются здесь
  }
});

Ключевой принцип заключается в перехвате импортов определённого формата, например:

import data fr om "https-data:https://api.example.com/users";

Такая схема позволяет интерпретировать импорт как запрос к внешнему ресурсу.


Перехват импорта через onResolve

Первым этапом является определение, какие модули должны обрабатываться плагином. Для этого используется onResolve.

build.onResolve({ filter: /^https-data:/ }, (args) => {
  return {
    path: args.path,
    namespace: "https-data"
  };
});

Основные задачи onResolve:

  • выделение специальных префиксов импорта;
  • назначение namespace для последующей обработки;
  • контроль маршрутизации модулей внутри сборщика.

Namespace играет критическую роль, так как он изолирует логику плагина от стандартных файловых модулей.


Загрузка данных через onLoad

После разрешения пути управление передаётся в onLoad, где происходит фактическое получение данных.

build.onLoad({ filter: /.*/, namespace: "https-data" }, async (args) => {
  const url = args.path.replace("https-data:", "");

  const response = await fetch(url);
  const json = await response.json();

  return {
    contents: `export default ${JSON.stringify(json)}`,
    loader: "js"
  };
});

Логика обработки:

  • извлечение URL из имени модуля;
  • выполнение HTTP-запроса;
  • преобразование JSON в JavaScript-модуль;
  • возврат результата в виде ES-модуля.

Такой подход превращает внешний API в часть графа зависимостей.


Преобразование данных в модуль ES

Esbuild ожидает, что результат onLoad будет представлен как валидный модуль. Наиболее универсальный способ — генерация строки с экспортом:

export default {...}

В случае более сложных структур можно формировать именованные экспорты:

return {
  contents: `
    export const users = ${JSON.stringify(json.users)};
    export const meta = ${JSON.stringify(json.meta)};
  `,
  loader: "js"
};

Это позволяет интегрировать внешние данные в систему tree-shaking и статического анализа.


Обработка текстовых и бинарных форматов

Внешние источники не ограничиваются JSON. Часто требуется поддержка CSV, XML или бинарных данных.

CSV преобразование

function parseCSV(text) {
  const [header, ...rows] = text.trim().split("\n");
  const keys = header.split(",");

  return rows.map(row => {
    const values = row.split(",");
    return Object.fromEntries(keys.map((k, i) => [k, values[i]]));
  });
}

Использование в плагине:

const text = await response.text();
const data = parseCSV(text);

return {
  contents: `export default ${JSON.stringify(data)}`,
  loader: "js"
};

Кэширование внешних запросов

Без кэширования сборка становится нестабильной и медленной. Esbuild позволяет реализовать собственный кэш на уровне плагина.

const cache = new Map();

build.onLoad({ filter: /.*/, namespace: "https-data" }, async (args) => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }

  const url = args.path.replace("https-data:", "");
  const res = await fetch(url);
  const data = await res.json();

  const result = {
    contents: `export default ${JSON.stringify(data)}`,
    loader: "js"
  };

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

Особенности кэширования:

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

Контроль ошибок загрузки

Работа с внешними источниками требует устойчивой обработки ошибок:

build.onLoad({ filter: /.*/, namespace: "https-data" }, async (args) => {
  try {
    const url = args.path.replace("https-data:", "");
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const data = await response.json();

    return {
      contents: `export default ${JSON.stringify(data)}`,
      loader: "js"
    };
  } catch (err) {
    return {
      contents: `throw new Error(${JSON.stringify(err.message)})`,
      loader: "js"
    };
  }
});

Такой подход позволяет:

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

Поддержка параметрических запросов

Часто внешний источник требует динамических параметров. В этом случае путь модуля может содержать query-параметры:

import users fr om "https-data:https://api.example.com/users?limit=10";

Разбор параметров:

const url = new URL(args.path.replace("https-data:", ""));
const lim it = url.searchParams.get("limit");

Использование параметров в запросе:

const response = await fetch(`${url.origin}${url.pathname}?limit=${lim it}`);

Это позволяет строить гибкие модули, зависящие от конфигурации импорта.


Виртуальные модули как слой абстракции

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

build.onResolve({ filter: /^virtual-api:/ }, (args) => {
  return {
    path: args.path,
    namespace: "virtual-api"
  };
});
build.onLoad({ filter: /.*/, namespace: "virtual-api" }, async (args) => {
  const endpoint = args.path.replace("virtual-api:", "");

  const res = await fetch(`https://api.example.com/${endpoint}`);
  const data = await res.json();

  return {
    contents: `export default ${JSON.stringify(data)}`,
    loader: "js"
  };
});

Такая схема позволяет скрыть реальные URL и создать слой абстракции над API.


Интеграция с потоковой обработкой данных

При больших объёмах данных полезно использовать потоковое чтение:

const response = await fetch(url);
const reader = response.body.getReader();

let result = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  result += new TextDecoder().decode(value);
}

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


Оптимизация производительности

При работе с внешними источниками критично учитывать влияние на скорость сборки.

Основные оптимизации:

  • параллельные запросы для независимых модулей;
  • ограничение количества одновременных fetch-запросов;
  • кэширование на уровне процесса;
  • дедупликация одинаковых URL;
  • предварительная нормализация запросов.

Пример ограничения параллелизма:

let active = 0;
const queue = [];

async function limitedFetch(url) {
  if (active >= 5) {
    await new Promise(resolve => queue.push(resolve));
  }

  active++;

  try {
    return await fetch(url);
  } finally {
    active--;
    queue.shift()?.();
  }
}

Безопасность внешних источников

Работа с удалёнными данными требует строгого контроля:

  • запрет выполнения произвольного кода из ответа;
  • проверка MIME-типа ответа;
  • ограничение доменов;
  • валидация структуры JSON;
  • защита от циклических импортов.

Фильтрация доменов:

const allowedHosts = new Set(["api.example.com"]);

if (!allowedHosts.has(url.hostname)) {
  throw new Error("Domain not allowed");
}

Согласование с системой модулей Esbuild

Внешние данные становятся частью графа зависимостей, поэтому важно учитывать:

  • порядок разрешения модулей;
  • взаимодействие с другими плагинами;
  • корректную работу tree-shaking;
  • стабильность идентификаторов модулей.

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


Комбинирование с трансформацией кода

Внешние данные часто требуют дополнительной обработки перед экспортом:

const transformed = data.map(item => ({
  id: item.id,
  name: item.name.toUpperCase()
}));

return {
  contents: `export default ${JSON.stringify(transformed)}`,
  loader: "js"
};

Такой подход позволяет внедрять бизнес-логику прямо на этапе сборки, снижая нагрузку на runtime.


Использование нескольких источников

Плагин может агрегировать данные из разных API:

const [usersRes, postsRes] = await Promise.all([
  fetch("https://api.example.com/users"),
  fetch("https://api.example.com/posts")
]);

const users = await usersRes.json();
const posts = await postsRes.json();

return {
  contents: `
    export const users = ${JSON.stringify(users)};
    export const posts = ${JSON.stringify(posts)};
  `,
  loader: "js"
};

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


Структурирование сложных плагинов

При росте функциональности плагин разделяется на слои:

  • слой маршрутизации (onResolve);
  • слой загрузки (onLoad);
  • слой трансформации данных;
  • слой кэширования;
  • слой безопасности.

Такая архитектура снижает связность и упрощает поддержку при увеличении количества внешних источников.