Cannot find module означает, что Node.js не смог сопоставить спецификатор модуля с файлом или точкой входа пакета. Для CommonJS код ошибки обычно равен MODULE_NOT_FOUND, для загрузчика ECMAScript modules — ERR_MODULE_NOT_FOUND. Причина зависит не от формулировки сообщения, а от трёх вещей: какой загрузчик работал, что указано между кавычками и из какого файла начался поиск.
Не начинайте с переустановки всего проекта. Сначала отделите относительный путь вроде ./config.js от имени пакета вроде kleur, найдите импортирующий файл и проверьте структурированные поля ошибки. Так можно исправить одну неверную строку, отсутствующую зависимость или устаревшую сборку, не удаляя node_modules и lock-файл наугад.
Сначала узнай, какой загрузчик искал модуль
Прочитайте код ошибки и цепочку загрузки
Текст Cannot find module встречается в разных ситуациях, поэтому ориентируйтесь на error.code. Node.js считает код стабильнее текста сообщения, который может меняться между версиями.
CommonJS добавляет массив requireStack. Первый путь в нём ведёт к модулю, который непосредственно выполнил неудачный require(), следующие показывают цепочку до точки запуска:
try {
require('./config.cjs')
} catch (error) {
console.error({
code: error.code,
requireStack: error.requireStack,
message: error.message,
})
}
Если app.cjs загружает report.cjs, а тот запрашивает отсутствующий ./config.cjs, проверять нужно путь относительно report.cjs. Первая строка вашего исходного кода в обычном стеке показывает место сбоя, а requireStack — кто кого загружал.
У ESM-ошибки полезны code, полный URL искомого модуля и фраза imported from в сообщении:
try {
await import('./config.js')
} catch (error) {
console.error({
code: error.code,
url: error.url,
message: error.message,
})
}
Поле url, если оно есть в вашей версии Node.js, показывает уже разрешённый file: URL. Декодируйте его как URL, а не склеивайте строку вручную. Для статического import тот же адрес обычно напечатан в сообщении об ошибке. Не строьте обработку ошибок по полному тексту message: для ветвления подходит code.
Перед любыми изменениями зафиксируйте среду запуска:
node --version
pwd
node -p "process.cwd()"
Эти команды ничего не устанавливают и не удаляют. Они помогают заметить, что терминал, редактор, контейнер и CI запускают один и тот же файл из разных каталогов или на разных версиях Node.js.
Сначала классифицируйте спецификатор
Спецификатор — строка, переданная в require(), import или import(). От её формы зависит весь поиск.
| Спецификатор | Что ищет Node.js | Частая причина |
|---|---|---|
./config.js, ../lib/logger.js | Файл относительно импортирующего модуля | Неверный уровень ../, регистр, расширение или отсутствующий результат сборки |
/srv/app/config.js, file:///srv/app/config.js | Файл по абсолютному пути или URL | В окружении нет файла по этому адресу |
kleur, @scope/logger | Пакет, встроенный модуль или разрешённая точка входа | Зависимость не установлена в нужном workspace |
kleur/colors, @scope/logger/node | Подпуть пакета | Подпуть переименован или закрыт полем exports |
node:path | Встроенный модуль Node.js | Обычно опечатка в имени; установка из npm не нужна |
Префикс ./ значим. require('config') не ищет соседний config.js: это запрос пакета config. Для локального файла нужен require('./config') в CommonJS или, как правило, import './config.js' в ESM.
Как CommonJS разрешает require()
require('./config') разрешается относительно каталога файла, который вызвал require(). Для относительного пути CommonJS сначала проверяет файл как указан, затем варианты .js, .json и .node, а также правила каталога. Поэтому этот импорт может работать без расширения:
// src/report.cjs
const config = require('./config')
Для имени без ./, ../ или начального / CommonJS ищет пакет в node_modules рядом с импортирующим файлом, затем поднимается по родительским каталогам. Это не поиск от произвольного каталога терминала.
Проверьте решение без выполнения целевого модуля:
console.log(require.resolve('./config'))
console.log(require.resolve.paths('kleur'))
require.resolve() возвращает точный файл, который выбрал бы загрузчик, либо выбрасывает тот же MODULE_NOT_FOUND. require.resolve.paths() печатает каталоги поиска для пакета. Разместите такую временную диагностику в том модуле, где падает require(): запуск node -p "require.resolve('kleur')" из корня репозитория проверяет контекст [eval], который может не совпасть с контекстом проблемного workspace.
MODULE_NOT_FOUND может относиться не к пакету, который вы загрузили первым, а к его вложенной зависимости. Сравните имя в сообщении с первым элементом requireStack; не переустанавливайте верхний пакет только потому, что он стоит в вашей строке require().
Как ESM разрешает import
ESM разрешает относительный спецификатор как URL относительно import.meta.url импортирующего файла. Для таких импортов Node.js требует полное имя файла с расширением; автоматического подбора .js и index.js нет.
// src/report.js
import config from './config.js'
Если написать import config from './config', существующий config.js не будет найден обычным ESM-загрузчиком. Укажите расширение, которое существует во время запуска. В TypeScript-проекте исходник может называться config.ts, но скомпилированный report.js обычно должен импортировать ./config.js: Node.js выполняет выходные файлы, а не догадки о пути исходника.
Текущую базу и результат разрешения можно увидеть внутри ESM-модуля:
console.log(import.meta.url)
console.log(import.meta.resolve('./config.js'))
console.log(import.meta.resolve('kleur'))
import.meta.resolve() возвращает абсолютный URL в контексте текущего модуля и учитывает правила пакетов и exports. Для относительного file: URL результат сам по себе не доказывает, что файл существует: современные версии Node.js могут вернуть URL несуществующего файла. Сопоставьте его с error.url и файловой системой.
Если вместо ERR_MODULE_NOT_FOUND возникает ReferenceError: require is not defined, файл уже запущен как ESM, но код использует CommonJS API. Это другой класс ошибки; выбор между преобразованием импорта и явным CommonJS разобран в статье «require is not defined».
Рабочая директория и каталог импортирующего файла — не одно и то же
Команда node ./dist/app.js сначала находит входной файл относительно process.cwd(). После загрузки модуля его относительные импорты разрешаются уже от самого файла: CommonJS использует каталог __dirname, ESM — URL import.meta.url.
// /srv/app/dist/report.js
import './config.js' // /srv/app/dist/config.js при любом cwd
Рабочая директория всё же влияет на другие операции: относительный аргумент точки входа, выбор проекта некоторыми командами пакетного менеджера и обычные обращения к файлам вроде readFileSync('./config.json'). Последний путь файловая система разрешает не как импорт, а от process.cwd().
Поэтому смена каталога иногда «чинит» запуск, но не доказывает, что импорт разрешается от cwd. Проверьте отдельно:
console.log({ cwd: process.cwd(), moduleUrl: import.meta.url })
Если эти пути различаются, определите, какая операция упала: загрузчик модуля или чтение данных приложением.
Относительный путь: файл, регистр и результат сборки
Для локального импорта пройдите короткую проверку от адреса из ошибки:
- Откройте импортирующий файл из
requireStack,imported fromили стека. - Разрешите
./и../от его каталога, а не от корня репозитория. - Сверьте каждую букву и расширение с реальным именем файла.
- Если стек указывает в
dist,buildили другой выходной каталог, проверяйте именно его, а не толькоsrc.
Регистр важен. import './UserService.js' и файл userService.js могут случайно совпасть на файловой системе без учёта регистра и сломаться в Linux или CI. Исправьте имя в импорте либо переименуйте файл так, чтобы написание совпадало буквально; не добавляйте второй файл с почти тем же именем.
Сборка может оставить устаревший граф. Например, dist/report.js всё ещё импортирует удалённый ./config.js, хотя исходники уже используют ./settings.js. Другой вариант: package.json направляет main или exports в ./dist/index.js, но пакет workspace ещё не собран.
{
"exports": {
".": "./dist/index.js"
}
}
Сначала посмотрите фактический выходной каталог и настройки пакета. Затем запустите предусмотренную проектом сборку пакета-зависимости и повторите исходную команду. Не редактируйте сгенерированный dist вручную: следующая сборка затрёт правку.
Имя пакета: зависимость и workspace
Для bare-спецификатора проверьте, объявлен ли пакет у того приложения или workspace, который его импортирует. Наличие зависимости в корневом package.json или соседнем workspace не гарантирует доступ из потребителя.
Начните с команды своего пакетного менеджера:
npm ls kleur --depth=0
pnpm why kleur
npm ls показывает логическое дерево и помечает отсутствующие или некорректные зависимости. pnpm why показывает, почему пакет присутствует. Запускайте проверку в контексте нужного workspace или с фильтром проекта, иначе вы исследуете корень монорепозитория.
Проверьте также тип зависимости. Пакет, импортируемый при обычном запуске приложения, должен попадать в производственную установку. Если он объявлен только в devDependencies, среда с исключёнными dev-зависимостями закономерно его не найдёт.
Если зависимость уже объявлена в правильном манифесте, но установки нет, восстановите её по зафиксированному дереву:
pnpm install --frozen-lockfile
npm ci
Выберите одну команду по lock-файлу проекта; не запускайте обе. Если пакет ещё не объявлен, добавьте его штатной командой менеджера именно в workspace-потребитель. Не устанавливайте приложение глобально: глобальная установка обычно не участвует в локальном разрешении require('package') и import 'package'.
Не удаляйте node_modules, package-lock.json, pnpm-lock.yaml или другой lock-файл как первый шаг. Удаление lock-файла разрешает пакетному менеджеру выбрать другое дерево версий и может скрыть исходную причину новой несовместимостью. Полная установка по существующему lock-файлу уместна после того, как проверки показали отсутствующее или повреждённое дерево зависимостей.
Пакет найден, но подпуть закрыт через exports
Поле exports в package.json определяет публичные точки входа пакета. Если оно задано, внутренний файл нельзя импортировать только потому, что он физически лежит в node_modules.
{
"name": "@scope/logger",
"exports": {
".": "./dist/index.js",
"./node": "./dist/node.js"
}
}
Здесь разрешены @scope/logger и @scope/logger/node, но не @scope/logger/dist/private.js. Закрытый подпуть обычно даёт отдельный код ERR_PACKAGE_PATH_NOT_EXPORTED, а не ERR_MODULE_NOT_FOUND. Переустановка пакета это не исправит: используйте документированную публичную точку входа или совместимую версию API.
Если разрешённая запись exports ведёт в файл, которого нет в опубликованном пакете или локальной сборке, загрузчик уже может сообщить MODULE_NOT_FOUND либо ERR_MODULE_NOT_FOUND. Тогда проверьте установленную версию, содержимое пакета и результат его сборки. Не обходите контракт абсолютным путём внутрь node_modules: такой импорт зависит от внутренней раскладки и ломается при обновлении.
Что исправлять по результату диагностики
| Наблюдение | Исправление |
|---|---|
| В ошибке относительный путь, итоговый адрес неверен | Исправить ./ или ../ относительно импортирующего файла |
ESM ищет ./config, а рядом лежит config.js | Указать полное ./config.js |
| Ошибка появляется только в Linux или CI | Свести регистр импорта и имени файла к точному совпадению |
npm ls или pnpm why не показывает пакет у workspace-потребителя | Объявить зависимость у потребителя и запустить установку из корня монорепозитория |
Пакет объявлен, но npm ls или pnpm why не видит установку | Восстановить зависимости штатной командой по lock-файлу |
Код равен ERR_PACKAGE_PATH_NOT_EXPORTED | Перейти на публичный подпуть из exports, не искать скрытый файл |
Ошибка указывает на отсутствующий файл в dist | Собрать пакет, проверить main/exports и не править артефакт вручную |
После нахождения файла возникает Cannot access … before initialization | Искать порядок выполнения или цикл импортов, а не продолжать чинить разрешение |
Циклический импорт и ошибка разрешения происходят на разных этапах. ERR_MODULE_NOT_FOUND означает, что загрузчик не построил ребро графа до модуля. При цикле оба файла уже найдены, но один может прочитать экспорт до его инициализации; это разобрано в статье «Cannot access before initialization».
Короткий порядок действий: прочитайте code, url или requireStack; классифицируйте спецификатор; разрешите относительный путь от импортирующего файла либо проверьте зависимость и exports; только затем меняйте импорт, устанавливайте зависимости или пересобирайте пакет. Такой порядок сохраняет воспроизводимое дерево пакетов и показывает настоящую причину ошибки.