Свой модуль в 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 любой внутренний файл был доступен всем и навсегда становился частью вашего контракта.

exports
Точка входа сегодня
подпути + инкапсуляция
main
Легаси-поле
работает, но открывает все файлы наружу
ERR_PACKAGE_PATH_NOT_EXPORTED
Ошибка при обходе exports
проверено запуском
Источник: собственный замер, Node v22.23.1
main против exports: что видит пользователь пакета

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 в новом пакете?
Если пакет для приложений — как правило, нет: ESM понимают все живые версии Node. Если это библиотека для широкой аудитории, публикуют оба формата через условные пути в exports (import и require).
Чем `files` отличается от `.npmignore`?
Это два способа решить одну задачу: files — белый список, .npmignore — чёрный. Белый список безопаснее: при добавлении новых каталогов в проект они не попадут в пакет случайно.
Что будет, если опубликовать пакет с ошибкой?
Исправить нельзя — в npm версии неизменяемы. Публикуйте новую версию. Удалить (npm unpublish) можно только в первые 72 часа и только если от пакета никто не зависит.
Обязательно ли имя со scope, вроде @user/package?
Нет, но с ним проще: глобальные имена почти все заняты, а scope — ваше пространство. Учтите: публикация scope-пакета требует явного --access public, иначе npm попытается сделать его приватным.
Как проверить пакет перед публикацией?
npm pack соберёт архив, который увидит npm, — распакуйте и посмотрите, что внутри. Плюс npm publish --dry-run покажет список файлов без реальной отправки.