Мост: ставим Node и запускаем первый файл настоящим node

платформа≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

До сих пор файлы части запускал прогонщик учебника, а вы читали его запись. Пора запускать самим. Сегодня — какую ветку Node ставить и почему через менеджер версий, что происходит между нажатием Enter и первой строкой вашего файла и три канала, которыми процесс отвечает тому, кто его запустил: вывод, поток ошибок и код выхода. Отдельно разберём объект ошибки файловой системы — его вы увидите первым, как только файл не найдётся. Всё сверим настоящим node.

Разделы темы:·

У Node одновременно живут несколько веток — мажорных версий, и у каждой своё состояние. Первые полгода новая ветка зовётся Current: в неё приезжает почти всё, что принимают в Node. Затем чётная ветка получает статус LTS, долгой поддержки. Новое в неё переносят только проверенным, и два с половиной года она получает исправления: год активной поддержки, потом полтора года Maintenance — только исправления безопасности и критичных ошибок. Нечётные ветки LTS не становились и поддерживались меньше года.

ВеткаСостояние на 29 сентября 2026Дальше
26Current, последний выпуск 26.10.0LTS с 28 октября 2026, поддержка до 30 апреля 2029
24 KryptonActive LTS, последний выпуск 24.21.0Maintenance с 20 октября 2026, поддержка до 30 апреля 2028
22 JodMaintenance LTSподдержка до 30 апреля 2027
20 и 25поддержка закончилась 30 апреля и 1 июня 2026исправлений безопасности больше не будет

На сервер ставят LTS: ради неё график и существует, там «обновились, и ничего не сломалось» важнее новинок. Доска из части IV уезжала в образе node:24-slim — это LTS 24. Для этой части ставьте 26. Записанные прогоны сняты на ней — в ярлыке у каждого стоит 26.5.0, версия на машине автора в день съёмки, — и сверять свой вывод с записью проще на той же ветке, а 28 октября 2026 года 26 сама станет LTS.

С 27-й ветки график меняется — проект объявил об этом 10 марта 2026 года. Мажорная версия будет выходить раз в год, в апреле, и LTS станет каждая, а не только чётная: 27.0.0 выйдет в апреле 2027 года и в октябре получит LTS. Перед выпуском полгода идёт канал Alpha для ранних проб, в нём ещё разрешены несовместимые изменения.

Установщик с сайта Node ставит одну версию на всю машину, и первое же расхождение делает это неудобным. Учебный проект просит 26, рабочий застрял на 22, сервер живёт на 24, а переставлять Node туда и обратно никто не станет. Второе неудобство описано в документации npm: установщик ставит npm в каталог с такими правами, что глобальная установка пакетов может упасть с ошибкой прав. Поэтому npm сам советует ставить Node менеджером версий.

Менеджер держит версии в вашей домашней папке и решает, какой node найдёт терминал. Известнее всего два. nvm — скрипт для оболочек macOS и Linux: на Windows он работает только через WSL или похожие прослойки. fnm — одна программа, и Windows она поддерживает сама. Команды у них почти одинаковые; ниже — первая установка через fnm.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
Что сделатьfnmnvm
поставить веткуfnm install 26nvm install 26
включить в этом терминалеfnm use 26nvm use 26
версия для новых терминаловfnm default 26nvm alias default 26
что уже стоитfnm listnvm ls
файл версии проекта.node-version или .nvmrc.nvmrc
переключиться, зайдя в папкусамо, с ключом --use-on-cdnvm use в папке проекта; само — с хуком из README nvm в настройках оболочки

Файл версии кладут в git, в корень репозитория: тогда одна и та же ветка достаётся всем, кто работает над проектом. Читает этот файл менеджер, а не Node. Сам node про .nvmrc ничего не знает, и в терминале, где менеджер не подключён, файл ничего не меняет.

Какой именно node запустится, в macOS и Linux показывает which node. В пути есть имя менеджера, .nvm или fnm_multishells, — переключение работает; путь без него, например /usr/local/bin/node, — терминал нашёл другой Node.

Когда вы набираете node hello.mjs --name Аня, Node этой строки не видит. Её читает оболочка — zsh, bash, PowerShell. Она режет строку на слова по пробелам, снимает кавычки, находит программу node в каталогах из переменной PATH — туда её и прописал менеджер версий — и запускает, передав массив слов. Кавычки нужны оболочке, а не программе: "Анна Мария" в кавычках — одно слово, без кавычек — два.

В программе этот массив называется process.argv. Первым элементом Node кладёт полный путь к себе, вторым — полный путь к файлу, дальше идёт всё, что стоит после имени файла. Поэтому аргументы программы начинаются с индекса 2, и все они строки: --times 3 приходит строкой "3". process в песочнице нет, так что возьмём четыре готовых массива — ровно такие соберёт оболочка из строк в поле typed — и разберём их простым правилом: слово с -- — ключ, следующее слово — его значение. Прогноз: что достанется ключу name во втором запуске и что — ключу times в четвёртом?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Во втором запуске кавычек не было, и имя доехало половиной: Мария стала отдельным словом без ключа. Программа тут ни при чём — разрезала оболочка, ещё до запуска. В четвёртом ключ --times забрал себе соседа "--name", а Аня осталась без ключа. Наше правило не знает, какой ключ ждёт значения, а какой нет, и по одной строке этого не понять. Поэтому настоящему разбору типы ключей объявляют заранее — так и сделаем в следующем разделе.

Слова до имени файла — ключи самого Node, после — ключи вашей программы. node --watch hello.mjs перезапускает файл при каждом сохранении, а node hello.mjs --watch отдаёт --watch программе, и Node его не видит. Перепутанный порядок Node не прощает: node --name Аня hello.mjs заканчивается строкой node: bad option: --name ещё до того, как открыт файл.

Ещё две вещи на первый запуск. node без имени файла открывает REPL — построчный режим, похожий на консоль браузера; выход — .exit. А имя файла Node ищет от текущей папки терминала: node hello.mjs не из той папки падает с Error: Cannot find module и полным путём, где файл искали, — по этому пути сразу видно, где вы стоите.

Чтобы запустить у себя записанный прогон учебника, откройте в нём вкладку файла и нажмите «скопировать» — кнопка забирает открытый файл. Сохраните его в пустую папку под именем с вкладки, так же — остальные вкладки, и наберите в этой папке команду из строки под кодом; так запускается и сервер из первой статьи главы. Расширение .mjs делает файл ES-модулем при любых настройках, поэтому package.json для таких файлов не нужен, а .ts-файл Node тоже запустит сам, стерев типы. Если прогон подключает пакеты npm, их сначала придётся поставить.

Процесс отвечает тому, кто его запустил, тремя способами: печатает в stdout, жалуется в stderr и, завершаясь, возвращает код выхода — целое от 0 до 255. Текст читает человек, код — программы: оператор && в терминале, скрипт выката, конвейер CI. npm test возвращает код своего скрипта, и npm test && npm run deploy до выкладки не дойдёт, если тест завершился не с нулём. 0 значит «получилось», любое другое число — «нет», а какое именно — решает программа.

Соберём настоящий hello.mjs. Аргументы разбирает встроенный parseArgs из node:util: ему ключи объявляют заранее, с типом, и первые два элемента argv он отрезает сам. --times должен быть целым числом больше нуля; если нет, программа жалуется в stderr, ставит process.exitCode = 2 и здоровается один раз. Запускаем со словом вместо числа. Прогноз: сколько строк напечатает программа, в каком порядке и с каким кодом завершится?

прогон node 26.5.0 · 15 сентября 2026
Кавычки вокруг кириллицы в команде поставил прогонщик: оболочка их снимает, и в argv их нет
import { basename } from 'node:path';
import { parseArgs } from 'node:util';

// первые два элемента — полные пути к node и к файлу, у каждого читателя свои,
// поэтому печатаем от них только имена
const [nodePath, filePath, ...rest] = process.argv;
console.log('argv:', [basename(nodePath), basename(filePath), ...rest]);

// parseArgs сам отрезает первые два элемента; тип ключа объявляется заранее,
// иначе по одной строке не понять, значение ли следующее слово
const { values } = parseArgs({
  options: {
    name: { type: 'string' },
    times: { type: 'string', default: '1' },
  },
});
console.log('name:', values.name, '| times:', values.times, '| тип times:', typeof values.times);

let times = Number(values.times);
if (!Number.isInteger(times) || times < 1) {
  console.error(`--times ждёт целое число больше нуля, а пришло «${values.times}»; поздороваюсь один раз`);
  process.exitCode = 2;
  times = 1;
}

for (let i = 0; i < times; i++) {
  console.log(`привет, ${values.name}!`);
}
node hello.mjs --name 'Аня' --times 'два'

В первой строке — argv, от путей оставлены только имена: полные пути у каждого свои. times пришёл строкой, как и всё в командной строке, и слово два проверку не прошло. Жалоба ушла в stderr, но в записи стоит на своём месте: прогонщик пишет оба потока в одно место в порядке печати. Код выхода — 2, и всё же приветствие напечатано уже после того, как код был выставлен. process.exitCode программу не останавливает: он лишь задаёт число, которое процесс вернёт, когда закончит сам, — когда в очереди не останется ни таймеров, ни ожиданий.

process.exit(2) на том же месте завершил бы процесс сразу. Не случилось бы приветствия, и пропало бы всё, что ещё не выполнено и не дописано в вывод, — почему второе не теоретическая угроза, разберёт третья задача. Отсюда правило: код выставляют через exitCode, а exit зовут, только когда доделывать нечего и незачем.

КодКто ставитЧто случилось
0Nodeвсё выполнилось, ждать больше нечего
1Nodeнеобработанное исключение — процесс упал
2программапо давней договорённости «вызвали неправильно»; Node этот номер не занимает, так завершается, например, разбор аргументов в Python
9Nodeнеизвестный ключ самого Node: node --name Аня hello.mjs
13Nodeawait на верхнем уровне модуля ждёт промис, который так и не выполнился
128 + номер сигналаоболочкапроцесс убит сигналом; после Ctrl+C, сигнала SIGINT с номером 2, — 130

На кодах держится дальнейшая часть. Сервер с неверной настройкой завершается с 1 до первого запроса, а node --test возвращает 1, если упал хоть один тест: строки вывода скрипт выката не читает, а код видит.

Файлы в Node — модуль node:fs, и у его функций три вида: с колбэком, синхронный и на промисах из node:fs/promises. В новом коде берут последний: await readFile(…) читается сверху вниз, а неудача приходит исключением в catch. Исключение это — обычный Error, к которому Node дописал поля с подробностями от системы.

Пример заводит папку во временном каталоге, пишет в ней файл, дописывает и читает. Потом читает файл, которого нет, и печатает ошибку целиком — console.log(err), так, как вы увидите её в терминале. Напоследок читает только что прочитанный list.txt по короткому имени. Прогноз: какие поля будут у первой ошибки и почему последнее чтение не найдёт файл, который только что читали?

прогон node 26.5.0 · 15 сентября 2026
Временная папка и папка примера у каждого свои, поэтому в записи они заменены метками
import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// Своя папка во временном каталоге системы: пример не мусорит рядом с кодом,
// а суффикс mkdtemp не даёт двум запускам писать в один и тот же файл.
const dir = await mkdtemp(join(tmpdir(), 'notes-'));
const file = join(dir, 'list.txt');

await writeFile(file, 'купить хлеб\n');
await appendFile(file, 'позвонить маме\n');
const text = await readFile(file, 'utf8');
console.log('строк в файле:', text.trimEnd().split('\n').length);

try {
  await readFile(join(dir, 'нет-такого.txt'), 'utf8');
} catch (err) {
  console.log(err);
}

try {
  await readFile('list.txt', 'utf8');
} catch (err) {
  console.log(err.code, '| path:', err.path, '| текущая папка:', process.cwd());
}

await rm(dir, { recursive: true });
node notes.mjs

Первая строка ошибки собрана из её полей: имя Error, код ENOENT, описание, системный вызов open и путь. Дальше стек: внутренние кадры Node свёрнуты меткой, а notes.mjs:16:3 — строка вашего файла, где случилось чтение. В фигурных скобках — то, что Node добавил к обычной ошибке.

ПолеЧто в нёмЗачем
codeимя ошибки системы: ENOENT — нет такого файла или папкипо нему ветвятся: имя одно на macOS, Linux и Windows
errnoномер той же ошибки у системы, со знаком минуспочти никогда: у ENOENT это -2 в macOS и Linux и -4058 в Windows
syscallкакой вызов системы не удался: open, read, mkdirпонять, что делали: открывали, читали или создавали
pathпуть ровно в том виде, в каком его передали функцииувидеть, какой файл просили; у некоторых ошибок пути нет вовсе

Ветвятся по code, и вот почему. В части I ошибки различали через instanceof, но у файловой системы класс один на всех — Error. Текст сообщения тоже не годится: в нём путь, а он на каждой машине свой. Остаётся код: if (err.code === "ENOENT") читается одинаково где угодно.

Последняя строка вывода — ловушка первого запуска. Файл list.txt лежит во временной папке, а прочитать его по короткому имени не вышло. Относительный путь файловые функции считают от текущей папки процесса — папки, из которой его запустили, — а не от папки, где лежит файл или модуль. Это та же ловушка, что в задаче про шаблон письма, теперь на настоящем node. И в поле path лежит ровно list.txt: где его искали, ошибка не говорит. Когда относительный путь не нашёлся, печатайте process.cwd(), а соседний с модулем файл читайте по пути от папки модуля — join(import.meta.dirname, "template.txt").

КодЧто случилосьКак его получить
ENOENTфайла или папки нетпрочитать несуществующий файл; записать файл в папку, которой нет
EACCESнет правпрочитать файл, у которого права сняты
EISDIRждали файл, а это папкаreadFile папки: syscall — read, поля path нет
ENOTDIRв середине пути файл, а не папкаreadFile("list.txt/b.txt")
EEXISTуже существуетmkdir существующей папки без recursive; writeFile с флагом wx

writeFile папок не создаёт. ENOENT при записи чаще всего значит, что нет папки, а не файла: перед записью — mkdir(папка, { recursive: true }).

У каждого процесса три стандартных потока: вход stdin, вывод stdout и поток ошибок stderr. console.log пишет в stdout, console.error — в stderr, а stdin программа читает. По умолчанию все три подключены к терминалу, но оболочка переключает любой: < sets.txt отдаёт программе файл на вход, > report.txt уводит вывод в файл, | соединяет вывод одной программы со входом другой. Программа об этом не знает и знать не должна — она читает stdin и пишет в stdout, откуда бы ни пришли строки и куда бы ни ушли.

stdin — поток: данные приходят кусками, по мере чтения, и кусок не обязан кончаться на границе строки. Строки из кусков собирает createInterface из node:readline, а перебирает их for await — тот же for..of, только следующую строку он ждёт, как await ждёт промис. Цикл кончается, когда закрыт сам ввод: кончился файл или в терминале нажали Ctrl+D. Как поток устроен изнутри, покажет статья «Собираем читающий и пишущий поток».

Программа считает тоннаж тренировки: на входе подходы строками «упражнение вес», на выходе — строка на подход и итог. Строку, где вес не число, она пропускает, жалуется в stderr и в конце ставит process.exitCode = 1. Прогноз: в каком порядке встанут строки, дойдёт ли дело до итога и с каким кодом завершится процесс?

прогон node 26.5.0 · 15 сентября 2026
stdout и stderr записаны вперемешку, в порядке печати
import { createInterface } from 'node:readline';

let total = 0;
let skipped = 0;
let number = 0;

// строки приходят по мере чтения; цикл кончается, когда закрылся сам ввод
for await (const line of createInterface({ input: process.stdin })) {
  number += 1;
  const [exercise, weight] = line.split(' ');
  if (!/^\d+(\.\d+)?$/.test(weight ?? '')) {
    console.error(`строка ${number}: «${line}» — вес не число, пропускаю`);
    skipped += 1;
    continue;
  }
  total += Number(weight);
  console.log(`${exercise}: ${weight} кг`);
}

console.log(`тоннаж: ${total} кг, пропущено строк: ${skipped}`);
if (skipped > 0) process.exitCode = 1;
node total.mjs < sets.txt

Строки обработаны по одной: жалоба на «тяга сто» стоит между подходами, там, где её встретила программа, а последний жим после неё тоже посчитан. Итог напечатан, но код — 1: цифре целиком доверять нельзя, и скрипт, который отправит отчёт дальше, должен об этом узнать. Номер тот же, что у падения, и отличить одно от другого поможет stderr, а скрипту важен сам факт.

Зачем жалобе отдельный поток, видно в терминале. После node total.mjs < sets.txt > report.txt в файл уходят четыре строки подходов и итога, а на экране остаётся одна — про «тяга сто»: > уводит только stdout. Вывод остаётся чистым для следующей программы, а жалобу видит человек. Без < sets.txt та же программа ждёт ввода с клавиатуры: строки набираются по одной, конец ввода — Ctrl+D в macOS и Linux.

  • На сервер ставят LTS — на 29 сентября 2026 года это 24, а с 28 октября 2026 года и 26. Для этой части — 26: на ней сняты записанные прогоны. С 27-й ветки LTS станет каждая мажорная версия, и выходить они будут раз в год.
  • Node ставят менеджером версий, fnm или nvm: версии живут в домашней папке, а ветка проекта записана в .node-version или .nvmrc. Файл версии читает менеджер, а не Node.
  • Командную строку режет оболочка. В process.argv приходит массив строк: путь к node, путь к файлу, дальше аргументы программы. Кавычки склеивают слова, числа приходят строками, ключи до имени файла принадлежат Node.
  • Код выхода — ответ для программ: 0 — успех, 1 Node ставит при необработанном исключении, 9 — при неизвестном ключе, 130 оболочка покажет после Ctrl+C. process.exitCode задаёт код и даёт программе доработать, process.exit обрывает её вместе с недописанным выводом.
  • Ошибка файловой системы — Error с полями code, errno, syscall и path, и ветвятся по code. В path — путь в том виде, в каком его передали, а относительный путь считается от текущей папки процесса, поэтому соседний с модулем файл читают через import.meta.dirname.
  • stdin, stdout и stderr переключает оболочка: < отдаёт файл на вход, > уводит только stdout, и жалобы из console.error остаются на экране. stdin — поток, строки из него собирает readline, а код выхода сообщает, что в данных нашлись битые строки.

1/4В корне проекта лежит .nvmrc с числом 24, а node -v в этой папке печатает v26.8.2. Почему?

2/4Что сделает команда node --times 3 report.mjs?

3/4Файл scripts/report.mjs читает readFile("template.txt"), шаблон лежит рядом, в scripts. Из папки scripts всё работает, а node scripts/report.mjs из корня проекта падает с ENOENT. Что будет в err.path?

4/4Что останется на экране после node total.mjs < sets.txt > report.txt?

Флаг и знак равенства

Это разбор из раздела про командную строку с другими запусками. Он понимает только форму --ключ значение, и все три запуска разбирает неправильно. Научите его ещё двум формам. --name=Аня — значение после знака равенства в том же слове. --loud — флаг без значения: если за ключом идёт другой ключ или слова кончились, значение — true.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Сначала ищем знак равенства в самом слове, потом смотрим на соседа: следующее слово — значение, только если оно само не ключ.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Правило «сосед с двумя минусами — не значение» всё равно угадывает: короткий ключ вроде -v после --name оно примет за имя. Поэтому parseArgs из node:util не угадывает, а требует объявить тип: boolean-ключ значения не берёт никогда, string-ключ берёт следующее слово, а если оно начинается с минуса, отказывает с ERR_PARSE_ARGS_INVALID_OPTION_VALUE и советует записать значение через знак равенства: --name=-XYZ.

Настройки по умолчанию только для отсутствующего файла

Приложение читает настройки из JSON-файла, а при первом запуске файла ещё нет — тогда нужны настройки по умолчанию. readFile здесь модельный, но ошибки бросает так же, как Node: Error с полями code, errno, syscall. Сейчас loadSettings отдаёт умолчания на любую ошибку и прячет две настоящие поломки: вместо файла указали папку, а в файле обрывок JSON. Поправьте catch так, чтобы умолчания доставались только отсутствующему файлу, а остальные ошибки летели дальше.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Умолчания — только для ENOENT, всё остальное бросаем дальше тем же объектом:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Отсутствующий файл при первом запуске — нормальное состояние, а папка вместо файла и обрывок JSON — поломки, которые должен увидеть человек. Проглоченные, они превращаются в «настройки почему-то не применяются». У SyntaxError от JSON.parse поля code нет вовсе, и проверка по коду пропускает его дальше сама. Бросать нужно тот же err, а не новую ошибку: в нём остаются code, syscall и стек до места чтения.

В задаче про настройки в localStorage на испорченной строке, наоборот, брали умолчания, и там это верно: строку записала прежняя версия приложения, и пользователю её не поправить. Файл настроек на диске правит человек, и опечатку в нём он должен увидеть сразу.

Отчёт, который обрывается на середине

Скрипт печатает большой отчёт о тренировках и сообщает кодом выхода, что в данных есть строки без веса. В терминале и с > report.txt отчёт всегда полный. В CI вывод идёт по трубе в сборщик логов, и там отчёт иногда обрывается на середине, хотя код выхода верный. Найдите строку, которая это делает, и поправьте так, чтобы код выхода остался прежним.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Виновата последняя строка: process.exit(1) завершает процесс, не дожидаясь, пока вывод допишется. Замена:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В терминал и в файл Node на macOS и Linux пишет синхронно: к вызову process.exit каждая строка уже записана. В трубу — асинхронно: строки ждут в очереди процесса, пока читающая сторона заберёт уже отправленное, а process.exit очередь не ждёт. В пробных запусках на macOS из 200 000 строк, отправленных в трубу, из которой начинали читать через секунду, с process.exit(1) доезжали первые несколько тысяч — то, что успело уйти в буфер трубы, — а с process.exitCode = 1 доезжали все двести тысяч. Код выхода в обоих случаях 1.

exitCode процесс не обрывает: Node завершится сам, когда делать станет нечего, в том числе дописывать вывод, и вернёт этот код. Больше ничего менять не надо: после цикла программе и так нечего ждать.