Daily MaverickWHO MADE 2026?: Heroes, villains and moegoes: nominate your People of the Year 2026CNN TürkÖZET | Hollanda'dan, Yunanistan'a farklı tarifeInquirerPCG lifts travel suspension for small vessels in 3 Palawan townsPunchMFM’s Olukoya celebrates wife’s PhD success at London collegeInquirer EntertainmentA tiny hand, baby Vans and a new chapter for Avril Lavigne — motherhoodGhaflaRuto Meets Kiambu Elected Leaders at State House Ahead of 2027 ElectionsZDF heuteEntdecken Sie das ZDF-NachrichtenstudioMintNoel Tata has no quarrel with where the Tata Group is headed. His problem is with how it is getting there.Capital FMGovernment seeks real-time blood tracking system to address hospital shortages20 MinutenBeine gebrochen: Emeric überlebt 30 Stunden in GletscherspalteWirtualna PolskaKomisja ds. pedofilii reaguje na weto Nawrockiego. "Z niepokojem"RapplerLIVE UPDATES: 49th ASEAN Summit in Manila
The Daily Newsstand · Free, Always
Saturday, November 14, 2026

Node.js 26 стал LTS: пять мест, где переезд с 24 ломается, и как найти их заранее

Translate

28 октября Node.js 26 получил статус LTS с кодовым именем Lithium, а в ноябре вышел первый security-релиз этой ветки, 26.13.1. Платформы уже начинают ставить 26 по умолчанию для новых проектов, так что самое время переводить на неё сервисы, которые сейчас живут на 22 или 24.

Обычно переход между LTS проходит тихо: поменял версию в .nvmrc и Dockerfile, прогнал тесты, поехали. Тут тоже в основном так, но в 26 окончательно удалили несколько старых API, поменяли ABI нативных модулей, а официальные бинарники для Linux начали требовать библиотеку, которой нет в «тонких» базовых образах. Ломается это не в вашем коде, а в зависимостях десятилетней давности и в Dockerfile, на которые никто не смотрит.

Ниже разбор по схеме «проблема → воспроизведение → исправление → проверка». Все выводы взяты из реального прогона на Node.js 22.23.5, 24.22.3 и 26.13.1 (официальные бинарники linux-x64).

Что изменилось

Из списка изменений 26.0.0 и примечаний к LTS-релизу для обычного бэкенда важны такие пункты:

  • http.ServerResponse.prototype.writeHeader() удалён окончательно, остался только writeHead();

  • внутренние модули _stream_readable, _stream_writable, _stream_duplex, _stream_transform, _stream_passthrough и _stream_wrap удалены;

  • module.register() теперь выдаёт предупреждение при запуске, вместо него предлагают module.registerHooks();

  • флаг --experimental-transform-types удалён;

  • NODE_MODULE_VERSION поднят до 147, поэтому нативные аддоны, собранные под 24, не загрузятся;

  • начиная с Node.js 25 официальные бинарники для Linux слинкованы с libatomic, и её может не быть в системе.

Проверим каждый пункт.

Воспроизведение

Собрал маленькое приложение, в котором есть всё, что обычно находится в проекте лет пяти от роду:

app/
├── Dockerfile
├── server.js            # HTTP-сервер
├── stream_test.js       # использует старую библиотеку-стрим
├── register.mjs         # подключает хуки загрузчика
├── hooks.mjs
├── main.mjs
└── node_modules/
    ├── legacy-csv-stream/   # require('_stream_transform')
    └── fast-hash/           # нативный аддон, собран под Node 24

Сценарий 1. Контейнер не стартует: libatomic.so.1

Самый неприятный вариант, потому что падает ещё до первой строки вашего кода. Типичный Dockerfile, где официальный архив Node кладут в slim-образ:

FROM debian:trixie-slim
COPY node-v26.13.1-linux-x64/ /usr/local/
COPY . /app
CMD ["node", "/app/server.js"]

Docker на этой машине нет, поэтому я скачал слои нескольких популярных базовых образов из публичных реестров, распаковал их в каталоги и запускал в них официальные бинарники Node 24 и 26 через bwrap. Для динамического линковщика это то же самое, что запуск внутри контейнера:

bwrap --bind rootfs-trixie-slim / --ro-bind node-v26.13.1-linux-x64 /opt/node \
  --dev /dev --proc /proc --tmpfs /tmp \
  /opt/node/bin/node -e 'console.log(process.version, "ok")'
rootfs-bookworm-slim       v24.22.3  v24.22.3 ok
rootfs-bookworm-slim       v26.13.1  error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory
rootfs-trixie-slim         v24.22.3  v24.22.3 ok
rootfs-trixie-slim         v26.13.1  error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory
rootfs-distroless-cc12     v24.22.3  v24.22.3 ok
rootfs-distroless-cc12     v26.13.1  error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory
rootfs-distroless-cc13     v24.22.3  v24.22.3 ok
rootfs-distroless-cc13     v26.13.1  error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory
rootfs-node26-slim         v24.22.3  v24.22.3 ok
rootfs-node26-slim         v26.13.1  v26.13.1 ok

Node 24 запускается везде, Node 26 не запускается ни в debian:bookworm-slim, ни в debian:trixie-slim, ни в distroless/cc-debian12 и cc-debian13. Разница видна прямо в ldd:

$ ldd node-v26.13.1-linux-x64/bin/node | grep atomic
	libatomic.so.1 => /lib/x86_64-linux-gnu/libatomic.so.1
$ ldd node-v24.22.3-linux-x64/bin/node | grep atomic
$

В официальном образе node:26-slim пакет libatomic1 уже установлен, поэтому, если вы берёте его как базовый, проблемы нет. Ломаются те, кто ставит Node сам: копирует архив в свой образ, собирает distroless-вариант или качает бинарник в CI-раннер с минимальной системой.

Сценарий 2. Старая библиотека требует _stream_transform

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

// node_modules/legacy-csv-stream/index.js
var Transform = require('_stream_transform');
var util = require('util');
function Upper() { Transform.call(this); }
util.inherits(Upper, Transform);
Upper.prototype._transform = function (chunk, enc, cb) { cb(null, String(chunk).toUpperCase()); };
module.exports = Upper;

На 22 и 24 всё работает и печатает ID;NAME. На 26.13.1:

Error: Cannot find module '_stream_transform'
Require stack:
- /workspace/habr/node26-lts/app/node_modules/legacy-csv-stream/index.js
- /workspace/habr/node26-lts/app/stream_test.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1603:15)

Хорошо, что падает при загрузке модуля, а не посреди ночи. Плохо, что падает в чужом коде, который вы не писали и, возможно, ни разу не открывали.

Сценарий 3. res.writeHeader is not a function

const http = require('node:http');
const srv = http.createServer((req, res) => {
  res.writeHeader(200, { 'content-type': 'text/plain' });
  res.end('ok\n');
});

На 22 и 24 сервер отвечает 200 ok. На 26 сервер стартует нормально и падает только на первом запросе:

/workspace/habr/node26-lts/app/server.js:3
  res.writeHeader(200, { 'content-type': 'text/plain' });
      ^

TypeError: res.writeHeader is not a function
    at Server.<anonymous> (/workspace/habr/node26-lts/app/server.js:3:7)

Это коварнее предыдущего случая: healthcheck, который просто проверяет, что порт открыт, пройдёт, а первый настоящий запрос уронит процесс. Если такой вызов сидит в редком обработчике, тесты без нужного маршрута его не заметят.

Сценарий 4. Нативный аддон, собранный под 24

node_modules собрали в CI на Node 24, закешировали и подложили в образ с Node 26:

Error: The module '/workspace/habr/node26-lts/app/node_modules/fast-hash/build/Release/fast_hash.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 137. This version of Node.js requires
NODE_MODULE_VERSION 147. Please try re-compiling or re-installing

Номера ABI у трёх версий такие:

$ node -p "process.version+' '+process.versions.modules+' napi '+process.versions.napi"
v22.23.5 127 napi 10
v24.22.3 137 napi 10
v26.13.1 147 napi 10

Обратите внимание на последнее число: версия Node-API у всех трёх одинаковая. Это подсказка к исправлению.

Сценарий 5. module.register() пока работает, но шумит

Хуки загрузчика через register() часто встречаются в инструментах: APM-агенты, моки, загрузчики TypeScript.

// register.mjs
import { register } from 'node:module';
register('./hooks.mjs', import.meta.url);

На 26 код продолжает работать, но в логе появляется:

(node:970200) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.

А если у вас в тестах или в проде стоит --throw-deprecation, процесс падает:

DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
    at node:internal/util:128:11
    at register (node:internal/modules/esm/loader:1014:3)
    at file:///workspace/habr/node26-lts/app/register.mjs:2:1

Попутно две мелочи. Флаг --experimental-transform-types, если он остался в скриптах package.json, теперь просто не даёт запуститься:

node: bad option: --experimental-transform-types

При этом обычный запуск .ts-файла с аннотациями типов без флагов на 26.13.1 работает. И ещё: проверка вида typeof localStorage !== 'undefined', которой некоторые библиотеки определяют «мы в браузере», на 26 по-прежнему даёт undefined, но печатает в лог ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. Ничего не ломается, но лишний шум в логах.

Как найти всё это до обновления

Тесты ловят только то, что выполнилось, а сценарии 2–4 сидят в node_modules и Dockerfile. Поэтому я написал простой скрипт, который ищет их статически:

#!/bin/sh
# check_node26.sh — ищет в проекте (включая node_modules) то, что ломается в Node.js 26
dir="${1:-.}"
echo "## Удалённые и устаревшие API"
grep -rnE --include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' \
  "require\(['\"](node:)?_stream_(readable|writable|duplex|transform|passthrough|wrap)['\"]\)|from ['\"](node:)?_stream_|\.writeHeader\(|\bregister\(['\"]|--experimental-transform-types" \
  "$dir" | grep -v 'registerHooks'
echo "## Нативные аддоны без Node-API (пересобирать под NODE_MODULE_VERSION 147)"
find "$dir" -name '*.node' -type f | while read -r f; do
  nm -D "$f" 2>/dev/null | grep -q 'napi_register_module_v1' || echo "$f"
done
echo "## Dockerfile: официальный бинарник Node без libatomic1"
find "$dir" -maxdepth 3 -name 'Dockerfile*' | while read -r f; do
  grep -qiE 'node' "$f" && ! grep -q 'libatomic' "$f" && grep -qiE '^FROM .*(slim|distroless)' "$f" && echo "$f"
done
true

Логика проверки аддонов такая: модуль на Node-API экспортирует символ napi_register_module_v1, и его ABI не зависит от версии Node. Если такого символа нет, аддон написан на старом API поверх node.h и V8, и его придётся пересобирать под каждую мажорную версию.

На тестовом приложении:

## Удалённые и устаревшие API
app/server.js:3:  res.writeHeader(200, { 'content-type': 'text/plain' });
app/node_modules/legacy-csv-stream/index.js:2:var Transform = require('_stream_transform');
app/register.mjs:2:register('./hooks.mjs', import.meta.url);
## Нативные аддоны без Node-API (пересобирать под NODE_MODULE_VERSION 147)
app/node_modules/fast-hash/build/Release/fast_hash.node
## Dockerfile: официальный бинарник Node без libatomic1
app/Dockerfile

Скрипт грубый: регулярка на register(' может зацепить чужую функцию с таким же именем, а проверка Dockerfile эвристическая. Но как первый проход по большому монорепозиторию он находит всё, что выше упало, и не требует ставить Node 26.

Второй дешёвый приём: прогнать тесты на текущей версии с --pending-deprecation --throw-deprecation. Тогда устаревшее, но ещё работающее начнёт падать заранее.

Исправление

libatomic. В Debian-образе ставим пакет:

FROM debian:trixie-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends libatomic1 \
 && rm -rf /var/lib/apt/lists/*
COPY node-v26.13.1-linux-x64/ /usr/local/
COPY . /app
CMD ["node", "/app/server.js"]

В distroless пакетного менеджера нет, библиотеку приходится приносить из промежуточной стадии:

FROM debian:trixie-slim AS libs
RUN apt-get update && apt-get install -y --no-install-recommends libatomic1

FROM gcr.io/distroless/cc-debian13
COPY --from=libs /usr/lib/x86_64-linux-gnu/libatomic.so.1* /usr/lib/x86_64-linux-gnu/
COPY node-v26.13.1-linux-x64/ /usr/local/

Собирать сам образ мне было негде, поэтому я проверял по-другому: поставил libatomic1 в распакованный trixie-slim и скопировал те же два файла (libatomic.so.1 и libatomic.so.1.2.0) в распакованный cc-debian13. Ну и самый простой путь, если ничего особенного вам не нужно: брать за основу официальный node:26-slim.

_stream_*. Всё, что было во внутренних модулях, давно доступно из публичного node:stream:

// было: var Transform = require('_stream_transform');
var Transform = require('node:stream').Transform;

Если это транзитивная зависимость, сначала поищите свежую версию пакета. Если пакет заброшен, правка в одну строку через patch-package или форк, а в долгую лучше заменить библиотеку.

writeHeader. Это был просто старый алиас:

res.writeHead(200, { 'content-type': 'text/plain' });

Нативные аддоны. Пересобрать под 26 (npm rebuild на той же версии Node, что и в рантайме) и, главное, не таскать node_modules с бинарниками между образами с разными версиями Node. В кеш CI стоит добавить версию Node в ключ. Для старого аддона я проверил пересборку руками: тот же исходник, скомпилированный с заголовками из архива 26.13.1, загружается на 26 и наоборот не загружается на 24.

Надёжнее перейти на Node-API: тот же «hello» на node_api.h собирается один раз и грузится на всех трёх версиях. Если аддон чужой, посмотрите, нет ли у него версии на Node-API или prebuild под 26.

module.register(). Переход на registerHooks(). Главное отличие: хуки синхронные и выполняются в том же потоке, а не в отдельном воркере, поэтому async и await внутри них не получится использовать:

// register.mjs
import { registerHooks } from 'node:module';
// синхронные хуки в том же потоке; next() тоже синхронный
registerHooks({
  resolve(spec, ctx, next) { return next(spec, ctx); },
});

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

Проверка браузера. Вместо localStorage проверяем то, чего в Node нет:

const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';

Проверка

Исправленное приложение прогоняем на всех трёх версиях с --throw-deprecation --pending-deprecation, чтобы любое предупреждение стало ошибкой:

for v in v22.23.5 v24.22.3 v26.13.1; do
  N="../bin/node-$v-linux-x64/bin/node --throw-deprecation --pending-deprecation"
  echo "######## $v"
  $N stream_test.js
  $N server.js
  $N storage.js
  $N --import ./register.mjs main.mjs
  $N -e "console.log(require('../addon/napi_addon.node').hello())"
done
######## v22.23.5
v22.23.5 ID;NAME
v22.23.5 200 ok
v22.23.5 isBrowser = false
v22.23.5 main loaded
hello from napi addon
######## v24.22.3
v24.22.3 ID;NAME
v24.22.3 200 ok
v24.22.3 isBrowser = false
v24.22.3 main loaded
hello from napi addon
######## v26.13.1
v26.13.1 ID;NAME
v26.13.1 200 ok
v26.13.1 isBrowser = false
v26.13.1 main loaded
hello from napi addon

Ни одного предупреждения, один и тот же код работает на 22, 24 и 26, так что откатиться можно в любой момент. Важно и то, что registerHooks() уже есть в 22.23.5 и 24.22.3, поэтому переписать хуки можно до обновления.

Образы с исправлением:

rootfs-trixie-fixed        v26.13.1  v26.13.1 ok
rootfs-distroless-fixed    v26.13.1  v26.13.1 ok

Внутри них исправленное приложение тоже отрабатывает:

rootfs-trixie-fixed v26.13.1 stream ok hello from napi addon
rootfs-distroless-fixed v26.13.1 stream ok hello from napi addon
v26.13.1 200 ok

Повторный прогон check_node26.sh по исправленному приложению выводит только заголовки разделов, находок нет.

Короткий чек-лист перед переходом на 26

  1. Если Node ставится не из официального образа node:26, добавьте libatomic1 и проверьте, что node -v выполняется внутри итогового образа, а не только на этапе сборки.

  2. Пройдитесь по проекту вместе с node_modules поиском _stream_, writeHeader( и register(.

  3. Найдите .node-файлы без Node-API и пересоберите их. Добавьте версию Node в ключ кеша node_modules.

  4. Перепишите module.register() на registerHooks(), пока вы ещё на 24.

  5. Прогоните тесты с --throw-deprecation --pending-deprecation.

  6. Сделайте healthcheck, который выполняет настоящий запрос, а не просто проверяет открытый порт.

А у вас уже был переезд на 26? Что сломалось первым: Dockerfile, нативный модуль или какая-нибудь библиотека из 2016 года в глубине node_modules? И как вы обычно ловите такие вещи: тестами, линтером или по первому падению на стейдже?

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.