Плагин для виртуальных модулей

Плагин виртуальных модулей в esbuild строится вокруг идеи подмены файловой системы логическими сущностями, которые существуют только в памяти сборщика. Виртуальный модуль не имеет физического файла на диске, но ведёт себя как обычный импортируемый модуль, проходящий этапы резолва и загрузки. Такая техника используется для генерации кода на лету, инъекции конфигурации, создания адаптеров окружения и построения абстракций поверх статических импортов.

Виртуальный модуль определяется как идентификатор, который:

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

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

Типичный сценарий применения:

  • конфигурация приложения через импорт
  • мокирование зависимостей
  • генерация API-клиентов
  • внедрение build-time констант
  • создание адаптеров окружения (browser/node)

Архитектура плагинов esbuild

Плагин в esbuild представляет собой объект с функцией setup, получающей API сборщика. Основные точки расширения:

  • onResolve — перехват импорта и переопределение пути
  • onLoad — загрузка содержимого модуля по виртуальному пути
  • namespace — изоляция виртуальных сущностей от реальной файловой системы

Связка этих механизмов формирует основу виртуальных модулей:

  1. Импорт перехватывается onResolve
  2. Путь заменяется на виртуальный идентификатор
  3. onLoad возвращает исходный код

Базовый паттерн виртуального модуля

Наиболее распространённая схема использует префикс:

  • virtual:config
  • virtual:env
  • virtual:api

Пример плагина

export const virtualPlugin = {
  name: 'virtual-modules',
  setup(build) {
    build.onResolve({ filter: /^virtual:/ }, args => {
      return {
        path: args.path,
        namespace: 'virtual'
      };
    });

    build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
      if (args.path === 'virtual:config') {
        return {
          contents: `
            export const config = {
              mode: "production",
              version: "1.0.0"
            };
          `,
          loader: 'js'
        };
      }

      if (args.path === 'virtual:env') {
        return {
          contents: `
            export const env = process.env.NODE_ENV;
          `,
          loader: 'js'
        };
      }

      return null;
    });
  }
};

В этом случае любой импорт:

import { config } from "virtual:config";

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

Разделение ответственности через namespace

Использование namespace критично для корректной работы виртуальных модулей. Оно позволяет:

  • отделить виртуальные ресурсы от файловой системы
  • избежать конфликтов с реальными файлами
  • ограничить область действия onLoad

Пример изоляции:

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

build.onLoad({ filter: /.*/, namespace: 'vmod-space' }, args => {
  return {
    contents: `export const id = "${args.path}"`,
    loader: 'js'
  };
});

Такой подход предотвращает случайное пересечение с реальными путями проекта.

Генерация модулей на основе параметров

Виртуальные модули часто параметризуются через строку импорта:

import { api } from "virtual:api/user";

Плагин может извлекать сегменты пути:

build.onResolve({ filter: /^virtual:api\// }, args => {
  return {
    path: args.path,
    namespace: 'api'
  };
});

build.onLoad({ filter: /.*/, namespace: 'api' }, args => {
  const entity = args.path.split('/')[1];

  const code = `
    export const endpoint = "/api/${entity}";
    export async function fetchData() {
      const res = await fetch(endpoint);
      return res.json();
    }
  `;

  return {
    contents: code,
    loader: 'js'
  };
});

Это превращает импорт в декларативное описание API.

Кеширование виртуальных модулей

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

Подход основан на Map:

const cache = new Map();

build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }

  const result = {
    contents: `export const value = "${args.path}"`,
    loader: 'js'
  };

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

Кеширование особенно важно при:

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

Интеграция с TypeScript-слоем

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

declare module "virtual:config" {
  export const config: {
    mode: string;
    version: string;
  };
}

Это позволяет сохранить статическую проверку при отсутствии физического файла.

Динамическая генерация на основе внешних данных

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

  • JSON-файлы конфигурации
  • переменные окружения
  • результаты HTTP-запросов на этапе сборки
  • метаданные файловой системы

Пример генерации из JSON:

import fs from "fs";

build.onResolve({ filter: /^virtual:json\// }, args => {
  return {
    path: args.path,
    namespace: 'json'
  };
});

build.onLoad({ filter: /.*/, namespace: 'json' }, args => {
  const file = args.path.replace("virtual:json/", "");
  const data = JSON.parse(fs.readFileSync(file, "utf8"));

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

Управление зависимостями виртуальных модулей

Виртуальный модуль может сам порождать зависимости. Для этого используются onResolve внутри цепочки:

  • виртуальный модуль A импортирует виртуальный модуль B
  • каждый из них обрабатывается отдельно через namespace
  • граф зависимостей строится как обычно

Пример каскадной генерации:

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

build.onLoad({ filter: /.*/, namespace: 'gen' }, args => {
  return {
    contents: `
      import { helper } from "gen:helper";

      export const value = helper() + 1;
    `,
    loader: 'js'
  };
});

Ограничения модели виртуальных модулей

Несмотря на гибкость, существует ряд ограничений:

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

Особенно критично поведение при инкрементальной сборке: изменение логики плагина может требовать полного пересчёта графа зависимостей.

Изоляция побочных эффектов

Генерация виртуального модуля должна оставаться детерминированной. Любые побочные эффекты приводят к нестабильности сборки. В рамках плагинов esbuild важно:

  • избегать случайных значений (Date.now, Math.random без фиксации)
  • не зависеть от внешнего состояния без контроля
  • обеспечивать воспроизводимость результата

Корректная модель предполагает, что один и тот же входной args.path всегда возвращает идентичный contents.

Использование виртуальных модулей как слой абстракции

На практике виртуальные модули часто становятся промежуточным DSL-слоем:

  • описывают инфраструктуру приложения
  • скрывают сложность конфигурации
  • заменяют runtime-инициализацию build-time логикой
  • формируют статически анализируемый API поверх динамики

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