Свой модуль в Node.js и публикация в NPM
✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026
Содержание
Модуль в Node устроен предельно просто: каталог, в нём package.json, в нём поле с точкой входа. Всё остальное — детали, но именно в них за последние годы всё и поменялось.
Минимальный модуль
Соберём пакет из трёх файлов.
package.json:
{
"name": "@hackerx/greet",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.js",
"./ru": "./src/ru.js"
},
"engines": { "node": ">=20" },
"files": ["src"]
}
src/index.js:
export function greet(name) {
return 'Hello, ' + name + '!';
}
export default greet;
src/ru.js:
export function privet(name) {
return 'Привет, ' + name + '!';
}
Всё. Теперь этот пакет импортируется:
import greet, { greet as named } from '@hackerx/greet';
import { privet } from '@hackerx/greet/ru';
console.log('default export :', greet('мир'));
console.log('named export :', named('мир'));
console.log('подпуть /ru :', privet('мир'));
default export : Hello, мир!
named export : Hello, мир!
подпуть /ru : Привет, мир!
Разберём поля, потому что каждое решает конкретную проблему.
type: module — иначе это не ESM
"type": "module"
Без этой строки Node трактует все .js в пакете как CommonJS, и export в них — синтаксическая ошибка. Альтернатива — расширение .mjs у каждого файла, но объявить тип один раз в package.json проще.
Это же поле отвечает на вопрос «почему мой import не работает» примерно в половине случаев.
exports вместо main: главное изменение
Раньше точка входа задавалась полем main:
"main": "./src/index.js"
Работает и сегодня, но у main два недостатка, которые решает exports.
Первое — подпути. С main пакет имеет ровно одну точку входа. Всё остальное импортируется по внутреннему пути к файлу: @hackerx/greet/src/ru.js. Это значит, что структура каталогов пакета становится публичным API: переименовали папку — сломали пользователей.
С exports вы объявляете карту:
"exports": {
".": "./src/index.js",
"./ru": "./src/ru.js"
}
Пользователь пишет @hackerx/greet/ru, а где лежит файл — ваше дело.
Второе, и более важное — инкапсуляция. exports не просто перечисляет пути. Он закрывает всё остальное:
await import('@hackerx/greet/src/index.js');
внутренний путь закрыт: ERR_PACKAGE_PATH_NOT_EXPORTED
Файл существует, лежит ровно там, но импортировать его нельзя — он не объявлен в exports. Это единственный способ иметь в пакете настоящие приватные модули: с main любой внутренний файл был доступен всем и навсегда становился частью вашего контракта.
main — легаси
Одна точка входа
"main": "./src/index.js"- Подпутей нет
- Любой файл внутри импортируем
- Структура каталогов = публичный API
- Переименовали папку — сломали чужой код
exports — сегодня
Карта путей + инкапсуляция
"."и"./ru"— объявленные входы- Подпути без привязки к файлам
- Всё необъявленное закрыто
- Обход →
ERR_PACKAGE_PATH_NOT_EXPORTED - Внутренности можно менять свободно
files: что реально уедет в реестр
"files": ["src"]
Белый список того, что попадёт в пакет. Без него npm упакует весь каталог, кроме нескольких служебных исключений: тесты, черновики, скриншоты, случайный .env с ключами.
Это не гипотетика — утечки токенов через опубликованные пакеты происходят регулярно, и почти всегда причина в том, что файл просто попал в архив.
Проверить, что именно уедет, можно до публикации:
npm publish --dry-run # покажет список файлов, ничего не отправит
npm pack # соберёт .tgz — распакуйте и посмотрите
Делайте это хотя бы раз перед первой публикацией. package.json, README.md и LICENSE попадают в пакет всегда, независимо от files.
engines: с какой версией Node это работает
"engines": { "node": ">=20" }
Поле-предупреждение: npm скажет пользователю, что его версия не подходит. По умолчанию это именно предупреждение, а не запрет — жёстко проверять нужно через engine-strict.
Ставьте сюда честную минимальную версию, а не текущую LTS «на всякий случай». Если ваш код использует Promise.withResolvers, минимум — Node 22; если ничего специфичного нет — не отсекайте пользователей без причины.
Публикация
npm login
npm publish --access public
Три вещи, которые стоит знать заранее:
--access publicобязателен для scope-пакетов. Без него npm попытается опубликовать@hackerx/greetкак приватный и откажет, если у вас нет платного аккаунта.- Версии неизменяемы. Опубликовали
1.0.0с ошибкой — исправить эту версию нельзя. Только1.0.1. Удалить можно лишь в первые 72 часа и только если от вас никто не зависит. - Версия — это обещание.
npm version patch|minor|majorподнимет её и создаст git-тег. Semver — не формальность: сломали обратную совместимость, но выпустили какminor— сломали чужие сборки.
Semver — это обещание, а не нумерация
Три числа в версии 1.4.2 — не украшение, а контракт с теми, кто вас установил.
| Часть | Когда поднимать | Что обещаете |
|---|---|---|
major (2.0.0) |
сломали совместимость | «ваш код может перестать работать» |
minor (1.5.0) |
добавили возможность | «старое работает как раньше» |
patch (1.4.3) |
починили баг | «ничего не изменилось, кроме бага» |
Нарушение контракта бьёт не по вам, а по чужим сборкам. В package.json пользователя стоит ^1.4.2 — это значит «любая 1.x.x не ниже 1.4.2». Выпустили ломающее изменение как minor — и у сотен людей приложение сломается само, без единой правки с их стороны.
Практические привычки:
npm version patch # 1.4.2 -> 1.4.3, плюс git-тег
npm version minor # 1.4.2 -> 1.5.0
npm version major # 1.4.2 -> 2.0.0
- Ноль в начале — особый режим. Пока версия
0.x.y, semver разрешает ломать что угодно:^0.4.2означает только0.4.x. Если API ещё не устоялся — честнее оставаться на нуле. - Удаление поля из
exports— это major. Даже если «этим никто не пользовался»: вы не знаете наверняка. - Обновление зависимости может быть major и для вас, если оно протекает в ваш публичный API.
Что было на этой странице в 2017 году
Первая версия вышла 24 ноября 2017 года — самая большая статья блога того времени. Она объясняла npm с нуля: что это, зачем, как ставить пакеты, как смотреть зависимости и как опубликовать своё.
Тогда упаковка модуля описывалась так:
«Первый способ основан на создании файла package.json с информацией о каталоге. Структура содержит разнообразную информацию, но к упаковке модуля относятся два свойства — name и main.»
Вот и вся разница между 2017-м и сегодня, в одной фразе. main действительно был единственным способом объявить точку входа: подпутей не существовало, инкапсуляции не существовало, любой файл внутри пакета был импортируемым и, значит, частью вашего публичного API. Поле exports появилось в платформе позже.
Код в той версии был на CommonJS — иначе и быть не могло:
const newArray = require('./arrayfunctions.js');
module.exports = mime;
ESM в Node тогда работал только за флагом. Сегодня export/import — норма, а module.exports встречается в основном в старых пакетах.
А вот что из той статьи не устарело совсем: npm init --yes по-прежнему создаёт package.json одной командой, и предупреждения npm при его отсутствии никуда не делись. Совет «создайте package.json, чтобы не раздражали предупреждения» работает дословно девять лет спустя.
Смежные темы: модуль fs — как читать файлы из своего пакета; объект process — как узнать версию Node в рантайме; глобальные объекты — что уже есть в платформе и ради чего не нужна зависимость. Полный список — в справочнике по Node.js.
Частые вопросы
Нужно ли ещё поддерживать require в новом пакете?
exports (import и require).Чем `files` отличается от `.npmignore`?
files — белый список, .npmignore — чёрный. Белый список безопаснее: при добавлении новых каталогов в проект они не попадут в пакет случайно.Что будет, если опубликовать пакет с ошибкой?
npm unpublish) можно только в первые 72 часа и только если от пакета никто не зависит.Обязательно ли имя со scope, вроде @user/package?
--access public, иначе npm попытается сделать его приватным.Как проверить пакет перед публикацией?
npm pack соберёт архив, который увидит npm, — распакуйте и посмотрите, что внутри. Плюс npm publish --dry-run покажет список файлов без реальной отправки.