Исключение встроенных модулей Node.js

При сборке приложений с помощью Esbuild нередко возникает необходимость исключить встроенные модули Node.js из итогового бандла. Такая практика особенно важна при разработке библиотек, серверных приложений, инструментов командной строки и универсального JavaScript-кода, который может выполняться в различных средах.

К встроенным модулям Node.js относятся:

  • fs
  • path
  • os
  • crypto
  • http
  • https
  • stream
  • events
  • url
  • util
  • zlib
  • net
  • tls
  • child_process
  • worker_threads
  • многие другие

Например:

import fs from 'fs';
import path from 'path';

const file = fs.readFileSync(
  path.join(process.cwd(), 'config.json'),
  'utf8'
);

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


Почему встроенные модули часто исключают из бандла

Существует несколько распространённых причин.

Сохранение нативного поведения Node.js

Модули вроде fs или path уже присутствуют в среде выполнения Node.js.

Нет смысла включать их реализацию в итоговый пакет:

import fs from 'fs';

После сборки импорт может остаться неизменным:

const fs = require("fs");

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


Уменьшение размера сборки

Некоторые сторонние зависимости используют встроенные модули Node.js. Если пытаться эмулировать их для браузера через полифиллы, размер бандла может значительно увеличиться.

Например:

import crypto from 'crypto';

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


Создание библиотек

При разработке npm-пакетов часто предполагается, что конечная среда выполнения уже содержит Node.js.

Пример:

import fs from 'fs';

export function loadConfig(file) {
  return fs.readFileSync(file, 'utf8');
}

Если такая библиотека будет использоваться в Node.js, нет необходимости встраивать модуль fs внутрь пакета.


Корректная работа серверных приложений

Серверный код часто зависит от системных возможностей:

import os from 'os';
import childProcess from 'child_process';

Подобные модули невозможно полноценно реализовать в браузере.

Исключение их из бандла позволяет избежать ошибок и сохранить ожидаемое поведение приложения.


Механизм external

Основной способ исключения модулей в Esbuild — использование параметра external.

Пример:

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

После сборки Esbuild не будет пытаться разрешать содержимое модуля fs.

Импорт сохранится:

require("fs");

или

import fs from "fs";

в зависимости от формата сборки.


Исключение нескольких встроенных модулей

На практике обычно исключают сразу группу модулей.

await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/app.js',
  external: [
    'fs',
    'path',
    'os',
    'crypto'
  ]
});

Теперь все указанные зависимости будут считаться внешними.


Использование API командной строки

Тот же результат можно получить через CLI.

esbuild src/index.js \
  --bundle \
  --outfile=dist/app.js \
  --external:fs \
  --external:path \
  --external:os

Каждый внешний модуль задаётся отдельным параметром.


Исключение всех встроенных модулей Node.js

Для крупных проектов перечисление каждого встроенного модуля вручную неудобно.

Node.js предоставляет список встроенных модулей через пакет module.

Пример автоматического формирования списка:

import { builtinModules } from 'module';
import * as esbuild from 'esbuild';

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

Содержимое builtinModules может выглядеть следующим образом:

[
  'fs',
  'path',
  'os',
  'http',
  'https',
  'stream',
  'crypto'
]

Количество модулей зависит от версии Node.js.


Особенность префикса node:

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

import fs from 'node:fs';
import path from 'node:path';

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

При работе с Esbuild необходимо учитывать оба варианта импорта.

Например:

external: [
  'fs',
  'node:fs',
  'path',
  'node:path'
]

Иначе часть импортов может остаться необработанной.


Автоматическое исключение модулей с префиксом node:

Практический пример:

import { builtinModules } from 'module';

const externals = [
  ...builtinModules,
  ...builtinModules.map(
    module => `node:${module}`
  )
];

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

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

Такое решение подходит для большинства серверных проектов.


Платформа node

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

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

Параметр:

platform: 'node'

сообщает Esbuild, что код предназначен для среды Node.js.

Это влияет на:

  • обработку импортов;
  • выбор условий экспорта пакетов;
  • генерацию кода;
  • поддержку встроенных модулей.

Связь между platform и external

Важно понимать различие между этими параметрами.

platform

Определяет среду выполнения:

platform: 'node'

external

Определяет зависимости, которые не должны попадать в бандл:

external: ['fs']

Даже если сборка предназначена для Node.js, внешние зависимости могут потребовать явного исключения.

Пример:

await esbuild.build({
  bundle: true,
  platform: 'node',
  external: ['fs']
});

Исключение встроенных модулей в ESM-сборках

Предположим, требуется получить ES-модуль.

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

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

import fs from 'fs';

console.log(fs.existsSync('file.txt'));

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

import fs from "fs";

console.log(fs.existsSync("file.txt"));

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


Исключение встроенных модулей в CommonJS

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

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

Результат:

var fs = require("fs");

Загрузка будет выполнена непосредственно Node.js во время запуска программы.


Исключение через шаблоны

Esbuild поддерживает шаблонные значения.

Пример:

external: ['node:*']

Теперь все импорты вида:

import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

будут автоматически считаться внешними.

Это особенно полезно в современных проектах, активно использующих пространство имён node:.


Типичная конфигурация серверного приложения

import * as esbuild from 'esbuild';
import { builtinModules } from 'module';

const externals = [
  ...builtinModules,
  ...builtinModules.map(
    module => `node:${module}`
  )
];

await esbuild.build({
  entryPoints: ['src/server.js'],
  bundle: true,
  platform: 'node',
  format: 'esm',
  target: 'node20',
  outfile: 'dist/server.js',
  external: externals
});

Особенности данной конфигурации:

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

Распространённые ошибки

Исключён только fs, но не node:fs

Неправильный вариант:

external: ['fs']

Код:

import fs from 'node:fs';

Может потребовать отдельной обработки.

Более надёжный вариант:

external: [
  'fs',
  'node:fs'
]

или:

external: ['node:*']

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

Пример:

import fs from 'fs';

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

platform: 'browser'

Модуль fs не существует в браузере.

Даже если он исключён через external, итоговый код всё равно не сможет корректно работать в браузерной среде.


Попытка использовать child_process в клиентском коде

import { exec } from 'child_process';

Модуль:

child_process

зависит от возможностей операционной системы и недоступен в браузере.

Его исключение из бандла не делает код переносимым между платформами.


Смешивание серверного и клиентского кода

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

src/
 ├─ server/
 │   └─ app.js
 └─ client/
     └─ app.js

Если клиентский модуль импортирует серверный:

import '../server/app.js';

то в дерево зависимостей попадут импорты вроде:

import fs from 'fs';

Это часто приводит к ошибкам сборки или выполнения.


Проверка результата сборки

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

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

import fs from 'fs';

Результат:

import fs from "fs";

или:

const fs = require("fs");

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


Практические сценарии использования

CLI-инструменты

import fs from 'fs';
import path from 'path';

Такие приложения почти всегда запускаются в Node.js и используют внешние встроенные модули.


Серверы Express

import fs from 'fs';
import http from 'http';

Включать встроенные модули в бандл не требуется.


Системные утилиты

import os from 'os';
import child_process from 'child_process';

Наиболее распространённая практика — помечать их как внешние зависимости.


Публикация библиотек

import path from 'path';

export function normalize(value) {
  return path.normalize(value);
}

Сохранение зависимости от встроенного модуля позволяет уменьшить размер пакета и избежать дублирования функциональности Node.js.