Копирование статических файлов

В экосистеме сборщиков модулей JavaScript работа со статическими файлами — изображениями, шрифтами, favicon, JSON-данными, HTML-шаблонами и прочими ресурсами — не всегда входит в базовый функционал. В Esbuild основная задача сосредоточена на обработке JavaScript/TypeScript и связанных модулей, тогда как копирование неизменяемых ресурсов реализуется через плагины или внешние шаги сборки.

Статические файлы обычно располагаются в отдельной директории (public, static, assets) и должны попадать в итоговую папку сборки без трансформации. Важный момент: Esbuild не предоставляет встроенной команды “copy assets”, поэтому архитектура решения строится вокруг плагинов и использования Node.js API.


Базовая модель работы со статикой

Типичная структура проекта:

project/
  src/
    index.ts
  public/
    favicon.ico
    robots.txt
    images/
      logo.png
  dist/

Папка public содержит файлы, которые должны быть перенесены в dist без изменений.

Esbuild при этом отвечает только за src, а копирование выполняется отдельным шагом или расширением через plugin API.


Ручное копирование через Node.js

Наиболее прямолинейный способ — использование стандартного fs API.

import fs from "fs";
import path from "path";

function copyDir(src, dest) {
  fs.mkdirSync(dest, { recursive: true });

  const entries = fs.readdirSync(src, { withFileTypes: true });

  for (const entry of entries) {
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);

    if (entry.isDirectory()) {
      copyDir(srcPath, destPath);
    } else {
      fs.copyFileSync(srcPath, destPath);
    }
  }
}

copyDir("public", "dist");

Этот подход прост, но плохо интегрируется с режимом watch, инкрементальными сборками и плагинами Esbuild.


Использование esbuild plugin API

Esbuild предоставляет механизм плагинов, позволяющий подключаться к этапам сборки. Для копирования статических файлов чаще всего используется хук onEnd.

import esbuild from "esbuild";
import fs from "fs";
import path from "path";

const copyPlugin = (options = {}) => {
  const { src = "public", dest = "dist" } = options;

  return {
    name: "copy-static",
    setup(build) {
      build.onEnd(() => {
        fs.mkdirSync(dest, { recursive: true });

        const copy = (from, to) => {
          const entries = fs.readdirSync(from, { withFileTypes: true });

          for (const entry of entries) {
            const fromPath = path.join(from, entry.name);
            const toPath = path.join(to, entry.name);

            if (entry.isDirectory()) {
              fs.mkdirSync(toPath, { recursive: true });
              copy(fromPath, toPath);
            } else {
              fs.copyFileSync(fromPath, toPath);
            }
          }
        };

        copy(src, dest);
      });
    }
  };
};

esbuild.build({
  entryPoints: ["src/index.ts"],
  bundle: true,
  outdir: "dist",
  plugins: [copyPlugin()]
});

Такой подход привязывает копирование к завершению каждой сборки, включая watch-режим.


Поведение в watch-режиме

При использовании watch: true Esbuild пересобирает проект при изменениях исходников. Однако onEnd срабатывает после каждого билда, что приводит к повторному копированию всей директории.

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

  • кэширование хешей файлов;
  • проверка временных меток (mtimeMs);
  • копирование только изменённых файлов;
  • использование debounce внутри onEnd.

Пример с простым debounce:

let timer;

build.onEnd(() => {
  clearTimeout(timer);

  timer = setTimeout(() => {
    copy("public", "dist");
  }, 50);
});

Инкрементальное копирование

Более эффективная стратегия — отслеживание изменений и копирование только обновлённых ресурсов.

const cache = new Map();

function shouldCopy(filePath, stats) {
  const prev = cache.get(filePath);

  if (!prev || prev !== stats.mtimeMs) {
    cache.set(filePath, stats.mtimeMs);
    return true;
  }

  return false;
}

Использование внутри обхода директории:

const stats = fs.statSync(filePath);

if (shouldCopy(filePath, stats)) {
  fs.copyFileSync(filePath, destPath);
}

Интеграция с путями вывода Esbuild

Esbuild поддерживает настройку outdir и outbase, что влияет на структуру выходных файлов. При копировании статики важно соблюдать согласованность структуры:

  • public/images/logo.png
  • dist/images/logo.png

Чтобы избежать дублирования логики путей, используется path.relative:

const relative = path.relative(src, filePath);
const destPath = path.join(dest, relative);

Обработка HTML и связанных ресурсов

Статические файлы часто включают HTML-файлы, которые не обрабатываются Esbuild по умолчанию. В таких случаях применяются два подхода:

  1. Копирование HTML без изменений.
  2. Предварительная обработка (например, вставка ссылок на бандл).

Простейшее копирование:

if (filePath.endsWith(".html")) {
  fs.copyFileSync(filePath, destPath);
}

При необходимости интеграции с бандлом добавляется пост-обработка:

  • замена <script src="...">
  • инъекция hashed-имен файлов
  • подмена путей к ассетам

Использование готовых плагинов

В экосистеме существуют готовые решения, реализующие копирование статических файлов:

  • esbuild-plugin-copy
  • esbuild-copy-static-files
  • esbuild-plugin-assets-manifest (частично для манифестов)

Типичная конфигурация:

import { copy } from "esbuild-plugin-copy";

esbuild.build({
  entryPoints: ["src/index.ts"],
  bundle: true,
  outdir: "dist",
  plugins: [
    copy({
      assets: {
        from: ["public/**/*"],
        to: ["dist"]
      }
    })
  ]
});

Плагины часто добавляют дополнительные возможности:

  • фильтрация по glob-выражениям;
  • исключения (ignore);
  • переименование файлов;
  • генерация manifest.json.

Генерация манифеста ассетов

При сборке SPA или SSR-проектов часто требуется сопоставление оригинальных файлов и их итоговых путей.

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

{
  "logo.png": "/assets/logo-8d91f2.png",
  "main.css": "/assets/main-a31c0c.css"
}

Хотя Esbuild не генерирует такой манифест автоматически для статических файлов, он может быть создан в plugin API:

const manifest = {};

build.onEnd(() => {
  fs.writeFileSync(
    "dist/manifest.json",
    JSON.stringify(manifest, null, 2)
  );
});

Обработка вложенных директорий и алиасов

При сложной структуре проекта возникают конфликты путей:

public/
  assets/
    assets/
      logo.png

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

const normalize = (p) => p.replace(/\\/g, "/");

Также важно учитывать поведение path.join, которое зависит от платформы.


Совмещение с loader’ами Esbuild

Esbuild поддерживает загрузку файлов через loader:

import logo from "./logo.png";

При этом файл не копируется автоматически как статический ресурс вне графа зависимостей. Он попадает в outdir только если импортирован.

Различие моделей:

  • loader — ассет как часть графа модулей;
  • static copy — внешний ресурс без импорта.

Эти подходы часто комбинируются:

  • изображения в коде через loader;
  • favicon, robots.txt через копирование.

Порядок выполнения шагов сборки

Типичная цепочка:

  1. Очистка dist
  2. Сборка Esbuild
  3. Копирование статических файлов
  4. Пост-обработка (манифест, HTML)
  5. Запуск dev server (при необходимости)

При этом копирование через onEnd может выполняться несколько раз в зависимости от режима.


Ограничения подхода

Модель копирования статических файлов в Esbuild имеет ряд ограничений:

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

Эти ограничения компенсируются гибкостью plugin API и возможностью интеграции с Node.js экосистемой.


Оптимизация крупных проектов

При увеличении количества ассетов применяются следующие техники:

  • разделение public на подкаталоги;
  • параллельное копирование через Promise.all;
  • использование потоков (fs.createReadStream);
  • исключение неизменяемых файлов из повторных операций;
  • интеграция с наблюдателями файлов (chokidar).

Пример параллельного копирования:

await Promise.all(files.map(file => {
  return fs.promises.copyFile(file.src, file.dest);
}));

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

В монорепозиториях статические ресурсы часто распределены между пакетами. В этом случае копирование выполняется:

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

Важно избегать конфликтов путей и дублирования ассетов между пакетами.


Работа с CDN-путями

В продакшн-сборках часто требуется подмена путей:

/assets/logo.png → https://cdn.example.com/logo.png

Реализация обычно выполняется на этапе пост-обработки:

const html = fs.readFileSync("dist/index.html", "utf-8");

const replaced = html.replace(
  /\/assets\//g,
  "https://cdn.example.com/"
);

fs.writeFileSync("dist/index.html", replaced);

Поведение при ошибках файловой системы

При копировании могут возникать ошибки:

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

Корректная обработка строится на try/catch:

try {
  fs.copyFileSync(src, dest);
} catch (e) {
  console.error("Copy error:", src, e);
}

Совместимость с ESM и CommonJS

Плагины Esbuild могут быть реализованы в обеих системах модулей. Однако при работе с файловой системой и путями предпочтение обычно отдаётся ESM:

import fs from "fs";
import path from "path";

Архитектурные подходы

Существует три устойчивых модели организации статических файлов в проектах с Esbuild:

  • отдельная директория public с копированием целиком;
  • частичное копирование через фильтры и glob;
  • полное включение ассетов в граф модулей через loaders.

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