TypeScript в Node.js: type stripping и compile cache¶
Источник: theNodeBook — TypeScript & Compile Cache
Node.js v24 может выполнять поддерживаемый синтаксис TypeScript, удаляя type-only конструкции перед запуском. В этой главе — механика stripping, форматы модулей для .ts, неподдерживаемый синтаксис и module compile cache. Type stripping работает для стираемого (erasable) синтаксиса типов. Конструкции, требующие генерации кода — например enum в режиме без transform — за пределами этого пути.
Поддержка TypeScript и compile cache в Node.js¶
Путь TypeScript в Node — это поддержка выполнения в runtime, а не замена компилятора. Node готовит исходник к выполнению. Проверка типов остаётся в инструментах TypeScript. Module compile cache сохраняет артефакты компиляции V8 на диск, чтобы следующие запуски процесса могли пропустить часть работы по компиляции; расположение и жизненный цикл кэша задаются API Node и переменными окружения.
Путь начинается с удаления синтаксиса, который runtime может игнорировать. Файл .ts попадает в обычный загрузчик модулей после того, как Node убирает аннотации, интерфейсы и прочий type-only текст.
Остаётся исполняемый JavaScript.
1 2 3 4 5 6 | |
Запустите этот файл в Node v24 — на выходе 3000. Без команды сборки. Без сгенерированного .js на диске. Node читает TypeScript-исходник, удаляет type-only синтаксис, который умеет стирать, и передаёт оставшийся JavaScript в обычный модульный путь.
Контракт узкий. Type stripping в Node — встроенный шаг подготовки исходника: удаление аннотаций TypeScript при сохранении JavaScript-программы. Режим strip-only по умолчанию: Node стирает inline-типы и сохраняет смещения байтов, заменяя удалённый текст пробелами. Проверка типов TypeScript в этот runtime-путь не входит. tsc может отклонить программу, которую Node всё равно запустит: Node готовит исполняемый JavaScript; доказательство типовой модели — задача checker’а.
1 2 | |
Node выполнит это и выведет 30001. Аннотация исчезает. Строка остаётся. Побеждают runtime-семантики: исполняемая программа по-прежнему присваивает строку.
Эта граница — суть главы. Node может убрать синтаксис без runtime-смысла. Node нужен transform, когда синтаксис TypeScript должен превратиться в новый JavaScript. Одни файлы запускаются. Другие падают до оценки. Импорты требуют реальных runtime-целей. А после подготовки исходника module compile cache может сохранить данные компиляции V8 для следующих запусков процесса.
Граница stripping¶
В файле TypeScript смешаны два вида информации.
Часть синтаксиса существует только для системы типов. Аннотация параметра, аннотация возврата, interface, type alias, type assertion и type-only import можно удалить, не меняя оставшихся JavaScript-операций. Стираемый (erasable) синтаксис TypeScript — то, что Node может удалить, потому что исполняемая форма программы после удаления та же.
1 2 3 4 5 | |
type UserId = string исчезает. : UserId исчезает. as string исчезает. Тело функции по-прежнему возвращает Promise.resolve(id). Runtime-объекта UserId нет, и Node нечего для него эмитить.
Режим strip-only намеренно мал. Node разбирает TypeScript достаточно, чтобы найти стираемый синтаксис, затем заменяет стёртые фрагменты пробелами, а не перепечатывает файл. Это важно: stack trace и позиции в исходнике остаются полезными, потому что длина и структура строк в основном сохраняются. Чистый stripping сохраняет позиции напрямую — путь по умолчанию обходится без source map.
Замена пробелами объясняет, почему путь отличается от компилятора. Компилятор обычно выдаёт новую программу: меняет отступы, нормализует синтаксис, понижает конструкции, переносит хелперы, переписывает импорты и строит source map к оригиналу. Путь Node по умолчанию меньше: исходник остаётся исходником, грамматика JavaScript получает почти тот же текст минус TypeScript-only регионы.
Парсер видит достаточно TypeScript, чтобы знать, что удалить. Загрузчик отправляет подготовленный JavaScript в ту же CJS/ESM-машинерию, что и для .js. Граф проекта не строится. Файлы деклараций не читаются. Цепочка наследования tsconfig.json не следуется. Готовится только текущий файл.
Есть следствие, которое часто упускают: ошибки на уровне типов могут соседствовать с успешным runtime.
1 2 3 4 5 | |
Node стирает : number и as any, затем вызывает функцию. В runtime приходит строка — и падает на отсутствии toFixed. Checker мог бы поймать вызов раньше, если проект не использует any, но Node уже прошёл этот слой. Прямое выполнение TypeScript полезно для запуска; качество типов — отдельный gate в редакторе, тестах или CI.
--no-strip-types отключает stripping.
1 | |
С отключённым stripping файл .ts с синтаксисом TypeScript попадает в парсер JavaScript как есть и падает на том, что JavaScript не разбирает. Флаг удобен, когда обёртка должна доказать отсутствие прямого выполнения TypeScript, или когда трансформацией владеет другой инструмент.
Опция компилятора erasableSyntaxOnly — на стороне TypeScript. Она просит отклонять синтаксис, требующий transform, при проверке — обратная связь в редакторе и CI до того, как файл увидит Node. Node эту опцию в runtime игнорирует, но она выравнивает цикл разработки с границей strip-only выполнения Node.
Парсер всё равно должен понимать файл. Плохой JavaScript — плохой JavaScript. Неподдерживаемый синтаксис TypeScript — другая ошибка: исходник валиден для TypeScript, но Node должен был бы сгенерировать JavaScript. В strip-only Node сообщает об этом через ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
1 2 3 4 5 6 | |
У enum есть runtime-представление в JavaScript. TypeScript обычно эмитит объектоподобную структуру. Удаление объявления изменило бы программу: Level.Warn нуждается в значении. Значит, enum — синтаксис, требующий transform. Node отклоняет его на пути по умолчанию.
Свойства параметров конструктора — та же проблема формы.
1 2 3 4 5 | |
public id: string в параметре — ещё и объявление поля, и присваивание. JavaScript нужен сгенерированный код, чтобы записать id на экземпляр. В strip-only нет присваивания после стирания — Node сообщает о неподдерживаемом синтаксисе.
Пространства имён (namespace) делятся на два случая. Namespace только с типами может исчезнуть. Namespace, создающий runtime-значения, требует эмита. Import aliases и часть устаревших конструкций — на той же стороне границы. Точный список задаёт парсер TypeScript в Node и может меняться между версиями, но рабочая модель стабильна: если удаление сохраняет исполняемый JavaScript — stripping возможен; если JavaScript нужно сгенерировать — stripping останавливается.
--experimental-transform-types просит Node перейти часть этой границы.
1 | |
В Node v24 флаг включает transform для поддерживаемого синтаксиса, требующего эмита — enum, свойства параметров и т.п. Node также печатает ExperimentalWarning. Считайте это runtime-удобством для контролируемых случаев. Сборка проекта по-прежнему у инструментов, которые владеют проверкой, emit деклараций, downlevel, path aliasing, JSX и артефактами сборки.
Имя флага намеренно прямое: stripping удаляет, transform эмитит. Как только Node генерирует JavaScript, он выбирает форму вывода и source map — для многих проектов это зона полноценного toolchain. Встроенная поддержка в runtime полезна для малого случая: скрипты, локальные утилиты, конфиги и сервисы на современном JavaScript с TypeScript в основном для аннотаций.
Декораторы — отдельно: они зависят от поддержки в JavaScript и поведения transform в TypeScript. Если парсер JavaScript в данной версии Node отвергает синтаксис декоратора, ошибка может выглядеть как ошибка парсера, а не ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Практический ответ тот же: нужен toolchain с transform.
Краткое правило — локально для файла. Мысленно удалите каждый type-only токен. Если остался валидный современный JavaScript с тем же runtime-поведением — путь по умолчанию, скорее всего, подходит. Если объявление TypeScript должно создать значение, присвоить свойство, переписать импорт или понизить синтаксис — нужен transform.
Расширения файлов и форма модуля¶
Выполнение TypeScript всё равно входит в систему модулей.
.ts — гибкая точка входа. Форма модуля для .ts определяется так же, как для .js: ближайшая граница пакета с "type": "module" даёт ESM, "type": "commonjs" или отсутствие поля — CommonJS. Глава 6 уже разбирала поле "type" в пакете; TypeScript stripping добавляет шаг подготовки исходника до того, как выбранный загрузчик компилирует подготовленный JavaScript.
1 2 3 | |
С таким package.json рядом node app.ts трактует app.ts как ESM по тем же правилам, что и .js. В пакете с CommonJS по умолчанию .ts следует тем же правилам детектирования и пакета, что и .js в текущей версии Node. Расширение .ts выбирает подготовку TypeScript; правила пакета и синтаксис файла — форму модуля.
Файл .ts может вести себя иначе при переносе через границу пакета.
1 2 3 4 | |
Если корневой пакет объявляет "type": "module", а packages/api/package.json — "type": "commonjs", два app.ts наследуют разные умолчания. Расширение .ts лишь говорит, что может сработать stripping; граница пакета всё равно задаёт форму модуля для неоднозначных TypeScript-файлов.
Синтаксическое детектирование тоже может иметь значение для неоднозначного ввода в современном Node. Файл с ESM-only синтаксисом подталкивает Node к ESM по документированным правилам. Точке входа сервиса лучше не полагаться на «сюрприз» детектирования: используйте .mts, .cts или явное "type" в пакете, когда форма модуля — часть контракта.
.mts и .cts снимают неоднозначность.
1 2 | |
.mts — точка входа ES-модуля. .cts — CommonJS. Расширения зеркалят .mjs и .cjs с добавлением TypeScript stripping перед компиляцией. Используйте их, когда форма модуля должна пережить перенос пакета или смену package.json.
Расширение относится и к зависимостям в графе. ESM .mts может импортировать другой .mts. CommonJS .cts может require() другой .cts. Смешанные графы по-прежнему следуют правилам CJS/ESM interop из главы 6. Stripping готовит исходник для существующих загрузчиков.
.tsx вне встроенного пути. Неподдерживаемая точка входа .tsx доходит до поддержки TypeScript в Node и падает: runtime TypeScript в Node — не среда выполнения JSX. Нужны компилятор, бандлер или сторонний runner.
Это полезная граница для backend: серверный скрипт .ts часто обходится без артефакта сборки. React-компонент в .tsx требует обработки JSX, а JSX — transform. Неподдержка .tsx не даёт прямому runtime притворяться frontend pipeline.
Node сохраняет модульный синтаксис: стирает типы, затем отдаёт остаток выбранному CJS или ESM загрузчику.
1 2 3 4 | |
Файлу нужна форма ESM: статический import и top-level await. Аннотации типов могут исчезнуть, форма модуля — нет. Если файл трактуется как CommonJS, парсер модулей отвергнет синтаксис или Node выберет другой путь загрузки по обычным правилам. Поддержка TypeScript не сглаживает эту границу.
CommonJS TypeScript тоже работает.
1 2 3 4 | |
После stripping это обычный CommonJS. Module._compile() по-прежнему оборачивает и компилирует. Module._cache кэширует результат оценки модуля. Stripping меняет байты исходника, которые получают существующие загрузчики.
Типичная ошибка CJS с ESM-привычками: статический import принадлежит ESM, даже в TypeScript-файле. .cts со статическим import просит CommonJS разобрать ESM-синтаксис. TypeScript при компиляции иногда переписывает это; strip-only Node оставляет модульный синтаксис как написан. В .cts — CommonJS-синтаксис, или делайте файл ESM через .mts или метаданные пакета.
Расширение влияет и на относительные спецификаторы. В TypeScript-импортах Node ожидает реальное расширение файла.
1 2 3 | |
Пишите ./config.ts. Резолвер Node работает с файлами в runtime. Компилятор TypeScript может проверять импорты с расширением при соответствующей настройке, но Node при runtime stripping игнорирует tsconfig.json. Runtime видит строку спецификатора. Если она ни на что не указывает — ошибка разрешения до оценки.
То же для require().
1 2 3 | |
У CommonJS есть историческое «угадывание» расширения для .js, но документация TypeScript в Node советует указывать расширение TypeScript явно. Прямое выполнение TypeScript уже зависит от поведения Node — скрывать расширение мало что даёт и создаёт неоднозначность резолвера.
При публикации JavaScript-артефактов спецификаторы должны соответствовать эмитированным файлам. Исходник с import './config.ts' для прямого запуска нельзя копировать в dist/app.js без dist/config.ts, если runtime снова должен его strip’ить. Здесь помогает rewriteRelativeImportExtensions на стороне компилятора: в исходнике для прямого запуска — .ts; в эмите — .js.
Импорты только для типов¶
Частый сбой: импорт выглядит нормально для TypeScript и ломается в runtime.
1 2 3 4 | |
Если UserRecord в user.ts — только interface или type alias, в runtime-модуле нет экспорта с таким именем. TypeScript доволен: имя есть в пространстве типов. Node строит исполняемый граф модулей — список импорта называет runtime-привязки, пока источник не помечен как type-only.
Исправление — явная пометка.
1 2 3 4 | |
Type-only import/export говорит TypeScript и Node, что привязка относится к типовому слою. Node стирает её при stripping. В runtime-списке импорта остаётся readUser. ESM linking проверяет привязку, которая реально существует.
У экспортов тот же разрез.
1 2 | |
Первая строка исчезает при stripping. Вторая участвует в runtime linking. Barrel, реэкспортирующий типы и значения, должен явно помечать типовую сторону — Node строит исполняемый граф из stripped-файла.
Default type import тоже.
1 2 3 4 | |
Если Config — интерфейс как default type, голый import Config from './config-type.ts' просит у ESM default runtime-экспорт. Его нет. import type убирает импорт до linking.
Сбой часто всплывает поздно при миграции: TypeScript выводит, что импорт используется только как тип. Старые настройки компилятора часто удаляли такие импорты при эмите JavaScript. Прямое выполнение в Node прогоняет текст исходника через stripper Node, а не emitter TypeScript — в тексте нужно сказать, какие имена type-only. Поэтому import type здесь не стиль, а входные данные runtime.
verbatimModuleSyntax — опция компилятора, удерживающая дисциплину. С ней TypeScript сохраняет ваш import/export синтаксис вместо переписывания по использованию имён. Это совпадает с взглядом Node: исчезающие импорты должны иметь маркер type в исходнике. Файл живёт в tsconfig.json; Node при выполнении его не читает. Ценность — обратная связь при разработке.
Опция снижает дрейф между dev и runtime. Если TypeScript молча переписывает импорты при проверке или эмите, проверенный граф может отличаться от того, что грузит Node. verbatimModuleSyntax возвращает различие в исходник: вы написали runtime-импорт или type-only — Node видит то же.
rewriteRelativeImportExtensions — ещё один компиляторный хелпер: при эмите JavaScript переписывает относительные расширения TypeScript, чтобы ./config.ts стал ./config.js в сборке. Для прямого выполнения в Node в импорте должно быть .ts как у runtime-файла; в дистрибутиве — расширение эмита. Отсюда у многих проектов разные конфиги для скриптов «запускаем .ts напрямую» и для публикуемого JavaScript.
Меньшая ловушка — exports в пакете. Пакет может отдавать runtime-файлы через subpath, а внутри исходников — относительные .ts-импорты. Node чтёт "exports" при package resolution; path aliases TypeScript остаются на стороне компилятора. Спецификатор с . или / — путь; bare — пакет или встроенный модуль. compilerOptions.paths категории runtime для встроенного пути не меняет.
Path aliases вне встроенного runtime.
1 2 3 | |
Если @app/config — только alias в tsconfig.json, Node трактует его как package-style спецификатор и идёт обычным резолвингом: пакет, import map — или ошибка. Встроенный stripping игнорирует compilerOptions.paths. Поведение alias добавляют runner, loader или бандлер.
Чистое правило прямого выполнения: каждый runtime-импорт в stripped-файле должен резолвиться обычным резолвером Node; каждая type-only привязка — с маркером type, чтобы исчезнуть до linking.
Тогда сбои локализуются: stripping отклонил синтаксис, резолвер не нашёл файл, linking не нашёл экспорт, evaluation бросила исключение. Скрытые переписывания компилятора размывают эти слои.
Eval, print и stdin¶
Для файлов на диске форму модуля задаёт расширение. У строкового ввода расширения нет — здесь нужны значения --input-type для TypeScript.
1 2 | |
--input-type=module-typescript — исходник --eval или stdin как TypeScript с семантикой ESM. Аннотация стирается, затем выполняется оставшийся модуль. Top-level await относится к модульной форме и здесь работает.
Строка на командной строке — всё ещё текст исходника. Кавычки и метасимволы обрабатывает shell до Node. Странное поведение eval часто — граница shell, а не TypeScript. При сомнениях смотрите точный argv или переносите фрагмент в stdin.
Для CommonJS есть свой режим.
1 2 | |
--input-type=commonjs-typescript стирает TypeScript и выполняет результат как CommonJS — удобно для shell-проб и маленьких сгенерированных фрагментов без временного файла.
--print сочетается с CommonJS TypeScript input type: печатается результат выражения в выбранном режиме.
1 2 | |
Node стирает аннотацию, оценивает ввод в стиле CommonJS и печатает 20. --input-type=module-typescript --print даёт ERR_EVAL_ESM_CANNOT_PRINT: печать значения выражения — CLI-проба в стиле CommonJS.
Флаг относится к строковым режимам входа. Для файлов на диске — расширение и граница пакета.
1 | |
Node отклонит команду с ERR_INPUT_TYPE_NOT_ALLOWED: --input-type только для --eval, --print или stdin.
Позиция флага всё ещё важна.
1 | |
--no-strip-types после точки входа — аргумент приложения. Node уже выбрал app.ts. Чтобы отключить stripping, флаг в зоне опций Node:
1 | |
NODE_OPTIONS тоже может нести эту настройку — Node читает её до разбора CLI. Обёртка с NODE_OPTIONS="--no-strip-types" ломает прямое выполнение TypeScript даже при видимой команде node app.ts. Баги старта сводятся к тому же следу из главы про CLI: какой слой «съел» флаг.
Упорядочите решения: переменные окружения → опции Node → режим входа (можно ли --input-type) → расширение или пакет для файлов → подготовка TypeScript → загрузка. Так исчезает большая часть путаницы вокруг .ts.
Зависимости в node_modules¶
Node отказывается strip’ить TypeScript внутри node_modules.
Это намеренно. Встроенный путь TypeScript — для контролируемого исходника приложения, скриптов и локальных утилит. Опубликованные зависимости должны поставлять исполняемый JavaScript. Если бы Node по умолчанию стирал TypeScript в пакетах зависимостей, выполнение зависело бы от версии Node и парсера у потребителя; авторам пакетов было бы соблазнительно публиковать исходник только под одну реализацию stripping.
Код ошибки: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
1 | |
Если импорт резолвится в такой файл и нужен встроенный stripping, Node останавливается с этой ошибкой. Исправление — на границе пакета: потребляйте JavaScript; просите автора публиковать исполняемые файлы; или toolchain, который владеет transform для зависимостей. Встроенный runtime держит контракт явным: зависимости уже должны быть runnable в Node.
Правило действует даже для полностью стираемого синтаксиса. Файл под node_modules только с аннотациями всё равно попадает под ограничение, если нужен путь stripping. Важна граница пакета, а не категория синтаксиса.
Локальный workspace может удивить: symlink, workspace менеджера пакетов или vendored dependency в итоге оказываются под путём node_modules в resolved URL. Ограничение следует финальному пути, а не намерению. При ошибке сначала смотрите resolved path.
Практический ответ упаковки тот же: публикуйте JavaScript, при необходимости — типы для checker’а; исходный TypeScript может жить в репозитории. Runtime-артефакт — JavaScript, если потребитель явно не выбрал инструмент с transform зависимостей.
Порядок ошибок¶
Прямое выполнение TypeScript проще отлаживать, если держать слои сбоев раздельно.
Используйте их как диагностические «корзины». Флаги старта разбираются до выбора исходника. После выбора entrypoint Node резолвит файл, готовит TypeScript для каждого загружаемого модуля, компилирует подготовленный JavaScript, связывает ESM-граф, оценивает. Первая видимая ошибка может прийти с разного слоя: transform-required синтаксис в импортёре падает до резолва одного из его импортов. Compile cache, если включён, участвует около компиляции. У каждого слоя свой профиль ошибки.
Начните с выбора источника.
1 2 | |
Падает до TypeScript-синтаксиса: CLI выбрал печать выражения с ESM-вводом. ERR_EVAL_ESM_CANNOT_PRINT на этапе entrypoint. Файл не резолвился. Stripping не начинался.
Отсутствующий файл — слой резолвера.
1 2 3 | |
Node достаточно разбирает импортёр, чтобы увидеть статический import, затем резолвинг падает: цели нет. Ошибка про спецификатор и URL. Менять на import type здесь неверно: readConfig — runtime-привязка. Цель должна существовать как исполняемый модуль.
Неподдерживаемый синтаксис TypeScript — подготовка исходника.
1 2 3 4 | |
В strip-only — ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Entrypoint резолвился. Загрузчик дошёл до подготовки TypeScript. В файле остался transform-required синтаксис. Compile cache ничего не меняет: до компиляции подготовленный JavaScript для этого модуля не доходит.
Импорт значения, именующий только тип, обычно падает на ESM linking.
1 2 3 4 | |
Если Settings — интерфейс, аннотацию можно стереть, но импорт всё равно просит runtime-экспорт Settings. Linking проверяет экспорты между module records и отклоняет граф. В исходнике нужно import type { Settings } ..., чтобы импорт исчез при подготовке.
Runtime-исключения — последними.
1 2 | |
Синтаксис стираемый. Файл компилируется. Linking успешен. Evaluation бросает: в runtime строка. Checker мог бы сообщить о типе отдельно; Node сделал своё — подготовил и выполнил JavaScript.
Этот порядок объясняет странные наблюдения с кэшем. Программа может заполнить файлы compile cache и всё равно упасть на linking или evaluation. Можно упасть на stripping и не получить полезный V8 code-cache для модуля. Можно получить hit compile cache и бросать каждый раз. Кэш помогает одному слою и не меняет правила следующих.
При сбое прямого .ts классифицируйте ошибку до смены инструментов. Резолвер — реальный спецификатор. Stripping — стираемый синтаксис или transform. Linking — runtime-экспорты совпадают с runtime-импортами. Evaluation — обыная отладка JavaScript. Ошибка tsc — checker, у runtime может не быть симптома.
Программный stripping¶
node:module экспонирует операцию stripping напрямую.
1 2 3 4 | |
module.stripTypeScriptTypes() принимает строку исходника и возвращает JavaScript с удалёнными типами TypeScript. Режим по умолчанию — 'strip', как у erasable-пути runtime. Бросает при transform-required синтаксисе, например enum.
В Node v24 API также печатает ExperimentalWarning. Stripping файлов в runtime стабильнее; этот программный хелпер ещё в статусе release-candidate.
В режиме strip возвращаемая строка сохраняет позиции за счёт пробелов.
1 2 | |
На месте : number остаются пробелы. Полезно, если следующий шаг — vm.compileFunction() или другой API, сообщающий позиции относительно подготовленного исходника. Неудобно, если ожидали «красивый» вывод — это работа компилятора.
API уместен, когда программа уже владеет текстом и хочет, чтобы парсер Node подготовил его перед низкоуровневым выполнением. Это не замена компилятора: не type-check, вывод по документации нестабилен между версиями Node. Сохранять его как артефакт сборки — привязка к деталям реализации Node.
Source URL — небольшой диагностический крючок.
1 2 3 4 | |
Node добавляет комментарий source URL для stack trace и инструментов. В режиме strip опция sourceMap: true недопустима: позиции сохраняются конструктивно. В режиме transform source map возможны — эмитированный JavaScript может не совпадать с входным текстом.
Есть и режим transform.
1 2 3 4 | |
С mode: 'transform' Node может эмитить JavaScript для поддерживаемого transform-required синтаксиса и строить source map. Это другой контракт, чем stripping с пробелами. Как только нужен сгенерированный JavaScript, важна форма вывода. Для сборки приложения по-прежнему tsc, бандлер или TypeScript-aware runner.
API проясняет порядок в runtime: поддержка TypeScript — шаг подготовки исходника до компиляции, linking и evaluation JavaScript. После подготовки загрузчики делают то же, что для .js.
Острый fit — маленькие контролируемые утилиты: runner конфигов, migration-скрипт, хелпер codegen с пользовательскими фрагментами TypeScript. Плохой fit — компиляция всего проекта: нужны граф, состояние checker’а, политика вывода и обычно декларации. API принимает одну строку.
Путь compile cache¶
Module compile cache сохраняет на диск данные компиляции V8.
Это главный ограничитель. Кэш значения модуля хранит результат оценки модуля внутри одного процесса. Для CommonJS в главе 6 был Module._cache: после оценки повторный require() в том же процессе возвращает тот же объект exports. Для ESM — module map с записями и состоянием оценки в текущем процессе.
Module compile cache — другой слой. Он персистит V8 code cache на диск, чтобы следующий процесс Node мог быстрее компилировать тот же исходник модуля. Code cache — сериализованный вывод компиляции, связанный с текстом и деталями runtime; V8 может пропустить часть парсинга или генерации байткода при совместимых условиях. Результат запуска модуля по-прежнему process-local.
С TypeScript люди слышат «cache» и ждут кэш stripped JavaScript как артефакт сборки. Compile cache ниже и уже: Node готовит исходник, V8 компилирует, V8 может сериализовать code cache. В каталоге кэша — приватные данные Node/V8, а не сгенерированный JavaScript.
Компактный след:
1 2 3 4 5 6 | |
Compile cache участвует на шаге compile. Подготовка TypeScript уже приняла или отклонила файл. Резолвинг уже нашёл файл. Для ESM проверка экспортов на linking всё равно должна завершиться до evaluation.
Проследим ESM .ts:
1 2 3 4 | |
Node резолвит entrypoint, читает исходник, stripping убирает аннотацию. В подготовленном JavaScript остаётся import. Node резолвит и грузит ./config.ts, готовит и его, передаёт подготовленный текст в компиляцию V8. С включённым compile cache Node может искать code cache для каждого скомпилированного CJS, ESM или TypeScript-модуля.
Для ESM компиляция V8 создаёт module records из подготовленного исходника. Linking проверяет импорты и экспорты между записями. Evaluation — после успешного linking. Compile cache может участвовать до того, как отсутствующий экспорт убьёт граф: артефакт кэша нацелен на работу компиляции; валидность импортов — забота linking.
Хороший тест — отсутствующий экспорт. Пусть app.ts импортирует { missing } из ok.ts, а ok.ts экспортирует только present. Node может скомпилировать обе подготовленные записи и записать code cache, затем linking сообщит об отсутствующей привязке. Следующий запуск переиспользует компиляцию и снова упадёт на linking. Кэш ничего «видимого» не улучшил — программа невалидна на более позднем слое.
У CommonJS другая форма загрузчика, тот же слой кэша после подготовки.
1 2 | |
Node стирает аннотацию, оборачивает подготовленный исходник для CommonJS, компилирует. С compile cache компиляция может использовать или создать on-disk code cache. После evaluation Module._cache держит exports в текущем процессе. На следующем старте процесса Module._cache пуст, а дисковый compile cache может уже содержать данные прошлого запуска.
В одном процессе Module._cache обычно скрывает повторную компиляцию CommonJS: второй require() возвращает тот же exports. Compile cache помогает между процессами, где Module._cache снова пуст. Выигрывают CLI, короткоживущие скрипты, serverless-стартеры, воркеры тестов — больше, чем долгоживущий сервис со стартом один раз.
У ESM похожее process-local состояние через ESM module map. Повторный статический импорт или поздний import() того же модуля в процессе переиспользует запись. На новом процессе map пуст; на диске compile cache может быть.
Три отдельных состояния:
- подготовка исходника: аннотации сняты или transform-required синтаксис отклонён;
- компиляция: V8 парсит и компилирует подготовленный JavaScript, с опциональной помощью on-disk code cache;
- оценка: код модуля выполняется, появляется process-local состояние модуля.
Они объясняют «странные» промахи кэша.
Hit compile cache всё равно запускает модуль. Top-level побочные эффекты снова выполняются на каждом старте процесса. Отсутствующий файл падает на резолвинге. Отсутствующий ESM-экспорт — на linking. Исключение evaluation — как обычно. Ошибка типов от checker’а — другой инструмент; compile cache о ней не судит.
Top-level побочные эффекты важны для оптимизации старта. Если config.ts открывает сокет, пишет в БД или логирует старт на верхнем уровне, compile cache может сократить время до побочного эффекта, но эффект всё равно произойдёт при evaluation.
Детали ключа кэша — детали реализации. Документация Node явна: файлы обычно переиспользуемы только при той же версии Node; разные версии держат отдельные данные под одним базовым каталогом. Абсолютные пути могут участвовать в инвалидации, если не включён portable mode. Смена содержимого исходника инвалидирует полезный артефакт — code cache должен соответствовать тексту, из которого создан.
Node защищает корректность при устаревшем кэше. Code cache привязан к исходнику и движку. При несовпадении Node компилирует нормально и может записать новые данные позже. Устаревший compile cache должен стоить времени и диска, не меняя поведение программы. Если удаление каталога кэша меняет результат программы — ищите баг в настройке запуска, не в кэше как оптимизации.
Ещё нюанс времени. В Node v24 code cache генерируется при свежей загрузке модуля, а накопленные данные пишутся на диск перед выходом процесса. module.flushCompileCache() форсирует сброс раньше — важно, когда родитель порождает дочерние процессы Node и хочет, чтобы дети переиспользовали кэш до своего exit.
Запись при exit значит: жёсткий краш может оставить меньше данных. Обычное исключение при корректном shutdown может всё же дать exit-work. SIGKILL не оставляет JavaScript-level cleanup. Относитесь к кэшу как к оппортунистическим performance-данным.
Первый процесс может быть медленнее: генерация кэша и диск. Следующие — быстрее, если грузят тот же граф при совместимых условиях. «Может» здесь работает по делу: старт зависит от I/O, размера графа, CPU, хранилища, инвалидации и времени вне компиляции JavaScript. Измеряйте, прежде чем считать кэш исправлением latency.
TypeScript делает кэш привлекательнее для скриптов — путь включает и подготовку, и компиляцию. Но compile cache хранит данные компиляции V8 после подготовки. Если доминирует stripping или чтение/резолвинг файлов, выигрыш может быть меньше ожидаемого.
Включение compile cache¶
Программный переключатель — в node:module.
1 2 3 4 | |
module.enableCompileCache() включает module compile cache для текущего экземпляра Node. Без аргумента directory использует NODE_COMPILE_CACHE, если задана; иначе — каталог под temp ОС. Возвращаемый объект содержит status и при успехе — directory.
Метод не бросает при сбое настройки — намеренно. Compile cache — оптимизация. Если каталог не создать, он read-only или отключён окружением, приложение обычно должно стартовать. Объект результата даёт launcher’у диагностику без фатальной зависимости от кэша.
В продакшене ветвитесь по status.
1 2 3 4 5 6 7 8 | |
Compile-cache status — целочисленный код enableCompileCache(). ENABLED — включили в этом вызове. ALREADY_ENABLED — раньше или через NODE_COMPILE_CACHE. FAILED — попытка не удалась. DISABLED — NODE_DISABLE_COMPILE_CACHE=1. Текст в message — вспомогательное; управляющее поле — status.
Так же обрабатывайте повторный setup: preload мог включить кэш до entrypoint; entrypoint снова вызывает enableCompileCache() — ALREADY_ENABLED успешен. Передавайте directory дочерним процессам, если нужно.
Форма через окружение проще для сервиса.
1 | |
NODE_COMPILE_CACHE включает кэш до кода приложения и задаёт базовый каталог. Чище, чем вызов в коде, когда цель — поведение старта для скриптов, CLI и сервисов.
Окружение доходит и до preload. Включение из кода приложения — после загрузки preload; preload уже скомпилирован без участия этого вызова. С NODE_COMPILE_CACHE preload может участвовать — переменная видна при старте. Для tooling с тяжёлым --require / --import порядок может иметь значение.
Программный setup уместен, когда CLI сам включает кэш до графа команд.
1 2 3 4 | |
Шанс покрыть cli-main.ts и всё загруженное после. Статические импорты вверху того же файла выполняются до тела модуля — вынесите вызов в крошечный bootstrap, если порядок важен.
У CommonJS bootstrap прямее.
1 2 3 4 | |
Вызов до следующего require(). Тот же принцип: включите до модулей, которые хотите покрыть.
Каталог должен быть одноразовым. Node может пересоздать. Удаление устаревших данных — нормальная уборка. Temp лучше каталога в исходниках: layout принадлежит Node и зависит от версии.
Нужны права: пользователь процесса должен создавать файлы под базовым путём. Read-only FS, жёсткий service account или путь образа контейнера как cache dir дают FAILED. Приложение может жить; статус должен быть виден в логах.
Активный каталог можно прочитать.
1 2 3 4 | |
module.getCompileCacheDir() возвращает активный каталог или undefined, пока кэш выключен или не активирован. Удобно для диагностики и настройки дочерних процессов.
Дочерние процессы Node — отдельные экземпляры. Настройка process-local, если не унаследована через окружение. Родитель с enableCompileCache() и spawn node child.ts — ребёнку нужен NODE_COMPILE_CACHE в env или свой вызов. Отдельные процессы: передайте env, если хотите общее хранилище.
Сброс явный.
1 2 3 4 5 | |
module.flushCompileCache() пишет накопленные данные для уже загруженных модулей. Node пишет и при exit, но ранний flush даёт следующим процессам шанс переиспользовать артефакты, пока текущий ещё жив. Сбой flush молчалив — промах кэша не должен ломать приложение.
Не стройте корректность на наличии файлов в каталоге кэша: layout меняется между версиями. Логируйте status при setup; вызывайте flushCompileCache(), когда важен timing; не проверяйте «есть ли файл» как invariant.
Есть portable mode.
1 2 3 4 | |
Portable mode ослабляет привязку к абсолютным путям проекта, когда пути можно выразить относительно каталога кэша. Best-effort: помогает, когда каталог проекта переезжает вместе с кэшем. Содержимое всё равно зависит от версии Node и совместимости исходника — это категория startup cache, не packaging.
Эквивалент в окружении: NODE_COMPILE_CACHE_PORTABLE=1. Ожидания скромные: если Node не вычислит полезный относительный путь для модуля, модуль может пропустить кэширование.
Предупреждение про coverage: V8 coverage может быть менее точной для функций, десериализованных из code cache. Для тестов с coverage отключайте кэш NODE_DISABLE_COMPILE_CACHE=1 или не включайте его. Корректность coverage важнее ускорения старта тестов.
NODE_DISABLE_COMPILE_CACHE=1 побеждает удобство: блокирует кэш даже при enableCompileCache() или NODE_COMPILE_CACHE — чистый override для CI и coverage без правок кода приложения.
Операционная форма проста: включайте при старте, кладите в одноразовый каталог, смотрите status, flush только когда другому процессу нужны данные раньше exit, и ожидайте тот же резолвинг, linking и evaluation, что без кэша.
TypeScript stripping и compile cache встречаются в одной точке: Node сначала готовит TypeScript в JavaScript, затем компиляция V8; кэш может ускорить повтор этой компиляции на следующем старте процесса, но runtime всё равно грузит тот же граф, проверяет те же импорты и выполняет тот же код.
Связанное чтение¶
- Предыдущая: Web Platform API в Node.js: fetch, Web Streams, Blob, FormData и structuredClone
- Далее: REPL, Inspector, Watch Mode и Single Executable Apps в Node.js