Бандлинг серверного кода

Бандлинг серверного кода представляет собой процесс объединения множества файлов приложения и зависимостей в один или несколько готовых артефактов для выполнения в среде Node.js. В отличие от фронтенд-разработки, где бандлинг ориентирован на браузеры, серверная сборка учитывает особенности платформы Node.js, систему модулей, встроенные API и требования к деплою.

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

Основные задачи серверного бандлинга:

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

Простая сборка Node.js-приложения

Исходная структура проекта:

src/
├── index.js
├── database.js
├── services/
│   └── userService.js
└── utils/
    └── logger.js

Файл входа:

import { createUser } from "./services/userService.js";

createUser();

Команда сборки:

npx esbuild src/index.js \
  --bundle \
  --platform=node \
  --outfile=dist/app.js

После выполнения команды создаётся файл:

dist/
└── app.js

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


Параметр platform=node

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

platform: "node"

или

--platform=node

Пример конфигурации:

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

Этот режим сообщает Esbuild, что код будет запускаться в Node.js.

В результате:

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

Работа со встроенными модулями Node.js

Рассмотрим пример:

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

console.log(fs.existsSync(path.resolve("./")));

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

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

Модули:

fs
path
http
https
crypto
stream
events

не будут встроены в итоговый бандл.

В результирующем коде останутся обращения к системным API Node.js.

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


Указание версии Node.js

Esbuild умеет генерировать код под конкретную версию Node.

Например:

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  target: "node20",
  outfile: "dist/app.js"
});

Или:

npx esbuild src/index.js \
  --bundle \
  --platform=node \
  --target=node20 \
  --outfile=dist/app.js

Возможные значения:

node14
node16
node18
node20
node22

Чем современнее целевая версия, тем меньше преобразований требуется выполнять Esbuild.


Форматы модулей для серверной среды

Node.js поддерживает несколько систем модулей:

  • CommonJS;
  • ECMAScript Modules (ESM).

Esbuild способен генерировать оба варианта.

Формат CommonJS

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

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  format: "cjs",
  outfile: "dist/app.js"
});

Результат:

const service = require("./service");

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


Формат ESM

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

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  format: "esm",
  outfile: "dist/app.js"
});

Результирующий код использует:

import ...
export ...

Для работы обычно требуется:

{
  "type": "module"
}

в файле package.json.


Использование внешних зависимостей

Иногда нет необходимости включать пакеты из node_modules внутрь бандла.

Например:

import express from "express";

Можно оставить пакет внешним:

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  external: ["express"],
  outfile: "dist/app.js"
});

В результате:

require("express");

останется без изменений.

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


Исключение нескольких библиотек

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  external: [
    "express",
    "pg",
    "mongoose",
    "redis"
  ],
  outfile: "dist/app.js"
});

Подход особенно полезен для:

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

Использование шаблонов в external

Допускается применение шаблонов:

external: ["aws-sdk/*"]

или

external: ["@aws-sdk/*"]

Пример:

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  external: ["@aws-sdk/*"],
  outfile: "dist/app.js"
});

Все пакеты AWS SDK будут исключены из итоговой сборки.


Полная автономная сборка

В некоторых сценариях требуется один файл без node_modules.

Например:

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  outfile: "dist/server.js"
});

Если не использовать external, Esbuild попытается встроить максимально возможное количество зависимостей.

Структура деплоя:

dist/
└── server.js

Запуск:

node dist/server.js

Подобный подход удобен для:

  • Docker-образов;
  • serverless-функций;
  • CI/CD-конвейеров;
  • однофайловых релизов.

Tree Shaking на сервере

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

Исходный модуль:

export function used() {
  console.log("used");
}

export function unused() {
  console.log("unused");
}

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

import { used } from "./utils.js";

used();

Во время сборки функция:

unused()

будет удалена.

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

  • уменьшение размера бандла;
  • сокращение времени запуска;
  • снижение потребления памяти.

Минификация серверного кода

Для production-сборок часто применяется минификация.

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

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  minify: true,
  outfile: "dist/app.js"
});

CLI-вариант:

npx esbuild src/index.js \
  --bundle \
  --platform=node \
  --minify \
  --outfile=dist/app.js

Минификация выполняет:

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

Работа с TypeScript

Esbuild способен одновременно компилировать TypeScript и выполнять бандлинг.

Исходный файл:

interface User {
  id: number;
  name: string;
}

const user: User = {
  id: 1,
  name: "Alex"
};

console.log(user);

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

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

Отдельный запуск TypeScript Compiler для базовой транспиляции не требуется.


Генерация Source Maps

Source Maps позволяют отлаживать уже собранный код.

Настройка:

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

После сборки появляются файлы:

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

Ошибки и стек вызовов можно сопоставлять с исходными файлами.


Разделение кода

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

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

await esbuild.build({
  entryPoints: [
    "src/api.js",
    "src/worker.js"
  ],
  bundle: true,
  splitting: true,
  format: "esm",
  platform: "node",
  outdir: "dist"
});

Esbuild создаёт:

dist/
├── api.js
├── worker.js
└── chunk-XXXXX.js

Общие зависимости помещаются в отдельные чанки.


Несколько точек входа

Часто серверное приложение состоит из нескольких процессов:

src/
├── api.js
├── worker.js
├── scheduler.js
└── migration.js

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

await esbuild.build({
  entryPoints: [
    "src/api.js",
    "src/worker.js",
    "src/scheduler.js",
    "src/migration.js"
  ],
  bundle: true,
  platform: "node",
  outdir: "dist"
});

Результат:

dist/
├── api.js
├── worker.js
├── scheduler.js
└── migration.js

Каждая точка входа получает собственную сборку.


Использование метафайла

Метафайл содержит подробную информацию о процессе сборки.

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

Получение данных:

const result = await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  metafile: true,
  write: false
});

console.log(result.metafile);

Метафайл помогает анализировать:

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

Режим наблюдения

Для разработки удобно автоматически пересобирать серверное приложение.

const context = await esbuild.context({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  outfile: "dist/app.js"
});

await context.watch();

После изменения файлов сборка запускается автоматически.

Обычно режим watch комбинируется с инструментами:

nodemon
node --watch
pm2
tsx

Серверная сборка Express-приложения

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

import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello");
});

app.listen(3000);

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

await esbuild.build({
  entryPoints: ["src/server.js"],
  bundle: true,
  platform: "node",
  external: ["express"],
  outfile: "dist/server.js"
});

Запуск:

node dist/server.js

Пакет Express будет использоваться из node_modules, а весь остальной код окажется внутри бандла.


Сборка серверного приложения с PostgreSQL

Пример:

import pg from "pg";

const client = new pg.Client({
  connectionString: process.env.DB_URL
});

await client.connect();

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

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  platform: "node",
  external: ["pg"],
  outfile: "dist/app.js"
});

Драйвер базы данных остаётся внешним модулем.

Подобная практика распространена для:

  • PostgreSQL;
  • MySQL;
  • Oracle;
  • MongoDB;
  • Redis.

Типовая production-конфигурация

import * as esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["src/index.ts"],
  bundle: true,
  platform: "node",
  target: "node20",
  format: "esm",
  minify: true,
  sourcemap: false,
  treeShaking: true,
  external: [
    "pg",
    "redis",
    "express"
  ],
  outfile: "dist/server.js"
});

Такая конфигурация обеспечивает:

  • современный JavaScript;
  • минимальный размер бандла;
  • быстрое время запуска;
  • удобный деплой;
  • эффективное удаление неиспользуемого кода;
  • сохранение внешних инфраструктурных зависимостей;
  • совместимость с современными версиями Node.js.