Хуки кастомизации модулей: перехват resolve и load
Хуки кастомизации перехватывают resolve и load для каждого модуля. Зарегистрированные через module.register в отдельном потоке, они образуют цепочку через next…-хуки. Хороши для dev-транспиляции и моков — но нагружают старт, а хук без source maps ломает каждый трейс.
Ты выкатил TypeScript-сервис, который в проде запускался прямо из исходников через загрузчик на register() — без шага сборки, «проще пайплайн». Через три недели в 2 часа ночи прилетает алерт: TypeError в billing.ts на строке 64. Открываешь строку 64 — а там пустая строка между двумя функциями. Каждый кадр стека сдвинут на десяток строк, потому что load-хук на лету транспилировал TS → JS и не выдал source map, так что V8 сообщает позиции в скомпилированном выводе, который ты в глаза не видел. Пятиминутная проверка на null превращается в двухчасовые раскопки сгенерированного кода. Хук-загрузчик сделал ровно то, о чём ты просил. Ты просто забыл, что теперь он владеет твоими трейсами.
Два хука: resolve и load
В пайплайне модулей Node есть два шва, в которые можно врезаться, и хук кастомизации — это функция, сидящая на одном из них. resolve(specifier, context, nextResolve) превращает спецификатор ("lodash", "./util.js", "data:...") в финальный, полностью квалифицированный URL плюс объявленный format ("module", "commonjs", "json", …). load(url, context, nextLoad) берёт этот URL и возвращает сам source (строку или буфер) и его format — и именно здесь можно преобразовать байты до того, как их увидит V8. Resolve отвечает на «какой файл»; load — на «что в нём». Транспайлер — это load-хук: он перехватывает .ts-URL, компилирует исходник и отдаёт JavaScript с format: "module".
Третий аргумент — это вся суть. nextResolve / nextLoad — это следующий хук в цепочке (или встроенный дефолт Node, если ты последний). Либо ты обрабатываешь запрос сам, либо вызываешь next…(specifier, context), чтобы делегировать дальше. Поэтому хуки композируются: резолвер tsx и резолвер инструмента покрытия могут быть оба зарегистрированы, каждый обрабатывает то, что ему важно, и передаёт остальное вниз.
// loader.js — a load hook that transpiles TS on the fly
import { readFile } from "node:fs/promises";
import { transform } from "some-fast-transpiler"; // e.g. swc/esbuild
export async function load(url, context, nextLoad) {
if (!url.endsWith(".ts")) {
return nextLoad(url, context); // not ours — defer down the chain
}
const raw = await readFile(new URL(url), "utf8");
const { code } = await transform(raw, { sourcemap: "inline" }); // ← keep maps!
return { format: "module", source: code, shortCircuit: true };
}Здесь живут две обязательные детали. shortCircuit: true говорит Node, что ты намеренно обработал запрос и не вызываешь nextLoad — опусти его, и Node бросит ошибку, чтобы хук тихо не съел цепочку. А sourcemap: "inline" — та самая строка, которую хук во вступлении забыл: без неё каждая позиция, о которой сообщает V8, — в скомпилированном выводе, а не в твоём .ts.
Регистрация хуков: module.register
Хуки не подключают вручную — их register()-ят, и Node поднимает их в отдельном потоке.
// app entry, run first: node --import ./register.js app.js
import { register } from "node:module";
register("./loader.js", import.meta.url); // (specifier, parentURL)register(specifier, parentURL) резолвит specifier относительно parentURL (передавай import.meta.url, чтобы относительный ./loader.js работал независимо от cwd), затем загружает этот модуль в отдельном worker-потоке и начинает направлять каждый последующий import/require через его экспорты resolve и load. Правило порядка, на котором обжигаются: хуки влияют только на модули, загруженные после запуска register, поэтому регистрация должна произойти до собственных импортов приложения — что как раз и гарантирует --import (он запускает файл до главной точки входа). Поставь register() в начало app.js — и импорт самого app.js, и всё, что он статически импортирует, уже зарезолвилось до того, как твой хук вообще существовал.
Чтобы передать конфигурацию внутрь хуков, работающих вне потока, register принимает опцию data и transferList, доставляемые в опциональный хук initialize, который выполняется один раз в потоке хука:
register("./loader.js", {
parentURL: import.meta.url,
data: { tsconfig: "./tsconfig.json" }, // structured-cloned to the hook thread
});
// loader.js
export async function initialize(data) {
// runs once on the hook thread; stash config, open a MessagePort, etc.
globalThis.__tsconfig = data.tsconfig;
}▸Почему это работает
Почему отдельный поток? Хуки-загрузчики до Node 20 выполнялись в том же контексте, что и твоё приложение, разделяя его граф модулей и глобалы. Это создавало проблему змеи, кусающей свой хвост: собственные import-ы загрузчика шли через сам загрузчик, так что хук мог уйти в дедлок, загружая свои же зависимости, а любое состояние, которого хук касался (пропатченный глобал, недоинициализированный модуль), утекало прямо в приложение. Перенос хуков на изолированный worker-поток обрубает это: граф хуков и граф приложения полностью раздельны, так что загрузчик может тянуть тяжёлые зависимости (целый транспайлер), не загрязняя и не гоняясь с приложением, для которого он и грузит. Цена изоляции — нельзя делиться живыми объектами: общаешься через structured-cloned data и MessagePort (двунаправленный канал передачи сообщений между потоками), а не через общую переменную.
Когда тянуться за хуком — и когда нет
Прежде чем подключать кастомный хук, спроси себя: это проблема dev-цикла или требование прода? Ответ почти всегда определяет верный выбор.
Хуки оправдывают себя в узком наборе случаев: транспиляция TypeScript/JSX на лету (tsx, ts-node, swc-node — все регистрируют load-хук), моки и инструментирование в тестах, кастомные протоколы (import x from "config:db"), ремаппинг в стиле import-map и инструменты покрытия, переписывающие исходник. Общий мотив — эргономика разработки и тестов: запуск кода как написан, без сборки.
Компромиссы реальны и накапливаются. (а) Хук выполняется для каждого модуля, так что медленный load добавляет свою стоимость линейно по всему дереву зависимостей — шаг транспиляции на load по ~30–50 мс на файл вхолодную превращает приложение из 400 файлов в лишние 12–20 секунд старта на каждый запуск node. (б) Модель «вне потока» означает отсутствие дешёвого общего состояния — из хука не дотянуться до глобалов приложения; всё идёт через порт. (в) Это нестабильная поверхность. API хуков переделывали между Node 18, 20 и 22 (в потоке → вне потока, удаление globalPreload, добавление register), так что загрузчик, привязанный к одной мажорной версии, может сломаться на следующей.
В совокупности: хуки дают быстрее dev-цикл ценой налога на старт каждого вызова, отсутствия дешёвого общего состояния и API, который скачет между мажорами. Сеньорское решение: транспилируй на этапе сборки для прода (одна стоимость, source maps выписаны на диск, стабильные артефакты), а хуки оставь для dev/тестов, где цикл без сборки стоит платы за каждый запуск.
Ты держишь TypeScript HTTP-сервис. В ПРОДЕ нужен быстрый, предсказуемый старт и трейсы, указывающие на реальные строки исходника. Как TS должен становиться исполняемым JS?
Что делает вызов nextLoad(url, context) внутри load-хука?
- 01Почему хуки кастомизации модулей перенесли с основного потока и что это стоит тебе?
- 02Хук транспиляции-на-load — причина того, что каждый прод-трейс указывает не на ту строку. В чём механизм и каков фикс?
Пайплайн модулей Node открывает два шва для перехвата, и хук кастомизации — это функция на одном из них: resolve(specifier, context, nextResolve) мапит спецификатор в финальный URL плюс format, а load(url, context, nextLoad) возвращает байты исходника — точку, где транспайлер превращает .ts в JavaScript до того, как его увидит V8. Аргумент next… — это следующий хук (или дефолт), так что хуки композируются: обработай своё, делегируй остальное, и помечай обработанный запрос через shortCircuit: true. Хуки устанавливают через module.register(specifier, parentURL), который грузит их в отдельном worker-потоке — изоляция, которая с Node 20 заменила опасную модель globalPreload в потоке, где загрузчик мог уйти в дедлок на своих же импортах или утечь глобалами в приложение; цена — нет общего состояния, так что конфиг приходит через data у register и хук initialize, а всё живое идёт через MessagePort. Хуки — верный инструмент для эргономики dev/тестов: TS/JSX на лету, моки, кастомные протоколы, покрытие, — но они выполняются для каждого модуля (~30–50 мс/файл транспиляции — это секунды старта на масштабе), не могут дёшево делиться состоянием, а API штормит между мажорами, так что прод должен транспилировать на сборке. И сбой, определяющий этот урок: транспилирующий load-хук без source maps заставляет V8 сообщать позиции в скомпилированном выводе, так что каждый трейс расходится с реальным исходником — выдавай встроенные maps или владей ночными раскопками.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.