Бандлер начинает с входного файла, проходит по всем import, преобразует то, что браузер не может выполнить как есть (TypeScript, JSX, импорты CSS, картинки), и записывает несколько оптимизированных файлов. Нативные ES-модули работают, но приложение без сборки грузит сотни файлов медленной цепочкой запросов.
Что он делает: превращает «голые» импорты вроде 'react' в пути к файлам в node_modules, транспилирует, выбрасывает неиспользуемые экспорты (tree shaking), минифицирует, делит код на чанки и добавляет в имя каждого файла хеш содержимого для кэширования.
Водопад запросов: браузер видит импорты модуля только после того, как скачал и разобрал его, поэтому цепочка импортов глубиной в десять уровней стоит десять сетевых раундтрипов. Бандл делает эту цепочку плоской.
Число запросов: один только lodash-es состоит из 600 с лишним модулей. HTTP/2 удешевляет каждый запрос, но не убирает ни накладные расходы на каждый модуль, ни водопад.
В разработке всё иначе: Vite в dev-режиме отдаёт модули без сборки, потому что запросы к localhost дешёвые, а всё, что текущая страница не импортирует, просто пропускается.
ReactMiddle
Инструменты и сборка фронтенда
Бандлеры, Vite и webpack, esbuild, SWC и Babel, tree shaking, Fast Refresh, source maps, переменные окружения, целевые браузеры и полифилы, npm, semver и lockfile, линтинг, безопасность зависимостей
Закрыто вопросов
0 / 18
- 0Повторить
- 5Часто спрашивают
Вопросы
Vite читает файлы .env во время сборки и подставляет вместо каждого обращения import.meta.env.VITE_* его буквальное значение прямо в итоговый JavaScript. Этот JavaScript скачивает каждый посетитель, поэтому всё с префиксом VITE_ публично, а переменные без префикса в клиентский код не попадают.
Файлы: .env для всех режимов, .env.production или .env.staging для одного режима (vite build --mode staging), варианты .local для значений, которые не попадают в git. Переменная, уже заданная в shell, важнее любого из этих файлов.
Префикс служит предохранителем, а не шифрованием: он не даёт серверным переменным утечь случайно. Поищите ключ в собранном бандле через DevTools, и он там найдётся.
Что туда можно класть: значения, публичные по своей природе, например publishable-ключ Stripe, DSN Sentry или ключ карт, ограниченный вашим доменом. Секретный ключ остаётся на сервере, а браузер обращается к вашему бэкенду, который добавляет ключ сам.
Одна сборка на окружение: значения вшиты в код, поэтому, чтобы продвинуть одну и ту же сборку со стейджинга в продакшен, конфиг нужно загружать в рантайме, например из /config.json.
Next.js делает то же самое с префиксом NEXT_PUBLIC_, а Create React App делал с REACT_APP_.
dependencies нужны вашему коду во время работы, devDependencies нужны только для сборки, тестов и линтинга, а peerDependencies описывают пакеты, которые библиотека ожидает получить от приложения-хоста, чтобы у приложения и библиотеки была одна общая копия.
Что устанавливается: в вашем проекте npm install ставит зависимости первых двух видов. Когда ваш пакет устанавливают в другой проект, с ним приезжают только его dependencies; на Node-сервере npm ci --omit=dev пропускает dev-зависимости.
Во фронтенд-приложении со сборкой это разделение важно меньше: Vite кладёт в бандл всё, что импортировано, где бы оно ни было перечислено.
Peer-зависимости нужны библиотекам: библиотека React-компонентов указывает react как peer. Если перечислить его в dependencies, может установиться вторая копия React, и хуки упадут с «Invalid hook call».
npm 7 и новее ставит peer-зависимости автоматически и останавливается с ERESOLVE при конфликте версий; --legacy-peer-deps прячет конфликт, а не решает его.
**optionalDependencies** могут не установиться, не ломая всю установку; через них esbuild поставляет отдельный нативный бинарник под каждую платформу.
^1.2.3 принимает любую 1.x.x начиная с 1.2.3 (новые минорные версии и патчи), ~1.2.3 принимает только патчи 1.2.x. Lock-файл записывает точную версию и контрольную сумму каждого пакета в дереве, а npm ci ставит ровно их, поэтому на каждой машине и в каждой сборке оказывается один и тот же код.
Semver: мажорная версия для ломающих изменений, минорная для новых возможностей, патч для исправлений. Это договорённость, а не гарантия: патч-релиз тоже может вас сломать.
До 1.0.0 каретка ^ строже: ^0.2.3 означает >=0.2.3 <0.3.0, потому что в 0.x повышение минорной версии считается ломающим.
Без lock-файла две установки с разницей в неделю разрешают одни и те же диапазоны в разные версии, включая глубокие транзитивные пакеты, которые вы нигде не указывали.
**npm install** разрешает диапазоны и может переписать package-lock.json; **npm ci** удаляет node_modules, ставит ровно то, что записано в lock-файле, и падает, если он не совпадает с package.json.
Коммитьте его (package-lock.json, pnpm-lock.yaml, yarn.lock или bun.lock), читайте его диф на ревью и обновляйте осознанно, через PR от Renovate или Dependabot.
В разработке Vite не собирает ваш код в бандл: он один раз пре-бандлит зависимости, а исходники отдаёт как нативные ES-модули и преобразует каждый файл в момент запроса браузера. webpack собирает в бандлы весь граф зависимостей до того, как отдаст первую страницу, поэтому время его старта растёт вместе с приложением.
Разработка
Пре-бандлинг зависимостей: пакеты из node_modules переводятся в ESM и склеиваются в несколько файлов (CommonJS нужно конвертировать, а lodash-es иначе дал бы 600 запросов), затем кэшируются в node_modules/.vite. Начиная с Vite 8 это делает Rolldown, раньше делал esbuild.
Исходники по требованию: .tsx-файл преобразуется в момент запроса, поэтому роут, который вы ни разу не открыли, ничего не стоит.
HMR: после правки заново отправляется только изменённый модуль, поэтому обновления остаются быстрыми по мере роста приложения.
Предел подхода: страница, которая импортирует тысячи модулей, делает тысячи запросов при первой загрузке, а barrel-файлы делают только хуже.
Продакшен
vite build собирает бандл через Rolldown (до Vite 8 через Rollup), делает tree shaking, делит код на чанки, минифицирует и добавляет хеши в имена файлов.
В разработке отдаются отдельные модули, а сборка склеивает их в бандл, поэтому некоторые баги (совместимость с CommonJS, порядок выполнения модулей) проявляются только в сборке. Перед релизом запускайте vite build && vite preview.
HMR подменяет изменённый модуль на работающей странице без перезагрузки: дев-сервер присылает изменение по WebSocket, а ближайший модуль, который принимает обновления, применяет его. Fast Refresh служит слоем React поверх HMR: он перерисовывает изменённые компоненты с новым кодом и сохраняет их стейт.
Границы: обновление поднимается по графу импортов, пока его не примет какой-нибудь модуль (import.meta.hot.accept в Vite, module.hot в webpack). Если раньше оно дошло до входной точки, страница перезагружается.
Как это делает React: React-плагин (трансформ на Babel, SWC или Oxc) регистрирует каждый компонент в файле и заставляет файлы, которые экспортируют только компоненты, принимать собственные обновления. Затем React подменяет функцию за каждым компонентом и сохраняет его значения useState и useRef.
Стейт сбрасывается, когда вы добавляете, удаляете или переставляете хуки в компоненте, у классовых компонентов, в файле с пометкой // @refresh reset и после исправления ошибки, которая уронила рендер.
Эффекты выполняются заново: в изменённом компоненте каждое обновление перезапускает useEffect, useMemo и useCallback независимо от зависимостей, поэтому эффект, который подписывается без очистки, копит слушатели.
Полная перезагрузка случается из-за файлов, которые экспортируют что-то помимо компонентов, и из-за правок обычных модулей, которые импортирует только входная точка. HMR существует только в разработке.
Файл экспортирует объект ThemeContext и хук useTheme рядом с компонентом, поэтому Fast Refresh не может обновить его на месте. Vite пишет «Could not Fast Refresh ("ThemeContext" export is incompatible)» и передаёт обновление выше, в main.tsx, который принять его не может, и страница перезагружается.
Почему важны экспорты: модуль может принять горячее обновление, только если после повторного выполнения каждый его экспорт либо компонент, либо прежнее значение. createContext возвращает новый объект, а useTheme оказывается новой функцией, так что оба считаются изменёнными; строковая или числовая константа остаётся равной прежней и не мешает.
Откуда берётся перезагрузка: Vite передаёт обновление модулям, которые импортируют этот файл. main.tsx импортирует ThemeProvider и не имеет HMR-обработчика, поэтому Vite откатывается к полной перезагрузке страницы, и весь стейт теряется.
Исправление: перенести контекст и хук в themeContext.ts. Тогда ThemeProvider.tsx экспортирует только компонент, и его правка обновляет страницу на лету, а форма остаётся заполненной.
Линтер это ловит: правило react-refresh/only-export-components в ESLint и react/only-export-components в Oxlint (включено в текущем шаблоне Vite для React) сообщают «Fast refresh only works when a file only exports components».
Лоадер преобразует по одному файлу в момент, когда webpack его импортирует, например TypeScript в JavaScript или Sass в CSS. Плагин подключается к компиляции и работает со сборкой целиком: генерирует HTML-страницу, выносит CSS в файлы, задаёт константы или анализирует бандл.
Лоадеры описываются в module.rules: test выбирает файлы, use перечисляет лоадеры, и они выполняются справа налево, поэтому в ['style-loader', 'css-loader', 'sass-loader'] первым компилируется Sass. По сути лоадер выглядит как (source) => newSource.
Плагины представляют собой классы с методом apply(compiler), которые подписываются на хуки жизненного цикла webpack: HtmlWebpackPlugin, MiniCssExtractPlugin, DefinePlugin, ModuleFederationPlugin.
Простое правило: работа с содержимым одного файла относится к лоадеру, а всё, что касается чанков, ассетов, выходных файлов или окружения, относится к плагину.
Ассеты: webpack 5 обрабатывает картинки и шрифты встроенными asset-модулями (type: 'asset'), которые заменили file-loader и url-loader.
В Vite и Rollup лоадеров нет: работу лоадера выполняет хук плагина transform(code, id), а остальное покрывают другие хуки (resolveId, load, generateBundle).
Rspack реализует тот же API на Rust, поэтому многие конфиги webpack переезжают на него почти без изменений.
Транспиляция переписывает синтаксис одного файла в другую форму (TypeScript и JSX в обычный JavaScript, новый синтаксис в более старый), а бандлинг проходит по импортам между файлами и собирает результат в чанки. Babel, SWC и esbuild транспилируют, не проверяя типы; типы проверяет только tsc.
Babel: написан на JavaScript, самый медленный из них, зато с самой большой экосистемой плагинов; React Compiler поставляется как плагин Babel.
SWC: транспайлер на Rust, его используют Next.js и @vitejs/plugin-react-swc.
esbuild: бандлер и транспайлер на Go; Vite использовал его для трансформаций и пре-бандлинга зависимостей, пока Vite 8 не заменил его на Oxc и Rolldown.
**tsc**: в приложении со сборкой он запускается с noEmit как отдельная проверка. TypeScript 7 (2026) переписан на Go и работает примерно в десять раз быстрее, но задача у него та же.
**Поэтому vite build проходит и с ошибками типов**: скрипт сборки в шаблоне Vite выглядит как tsc -b && vite build, а в CI проверка типов идёт отдельным шагом.
Транспайлер по одному файлу видит только этот файл: он не может понять, что реэкспортированное имя оказалось типом, оставляет его, и браузер падает с «does not provide an export named 'User'». isolatedModules (его включает verbatimModuleSyntax) заставляет tsc отклонить такой код заранее.
Удаляются только неиспользуемый parseCsv из ./utils и ненужные части lodash-es. lodash попадает в бандл целиком, потому что распространяется в формате CommonJS, все хелперы из dates остаются, потому что dates[helper] может обратиться к любому из них, а ./setupI18n остаётся, потому что его импортируют ради побочного эффекта.
Как это работает: импорты и экспорты ES-модулей статичны, поэтому бандлер отмечает используемые экспорты и выбрасывает код, до которого ничего не доходит, если его удаление не может изменить поведение.
**lodash**: его CommonJS-сборка навешивает все функции на один объект во время выполнения (module.exports = _), бандлер не может это проанализировать, и в бандл уходит вся библиотека (около 70 КБ после минификации). Из lodash-es остаются только throttle и мелкие внутренние хелперы, которые он импортирует.
**import * as dates**: импорт пространства имён нормально тришейкается при статическом доступе вроде dates.short, но вычисляемый ключ может оказаться любым экспортом, поэтому остаются все 40 хелперов.
**./setupI18n**: импорт без имён существует ради побочных эффектов и сохраняется всегда. Если пакет ошибочно объявляет "sideEffects": false, такие импорты, включая CSS-файлы, молча выбрасываются; вместо этого их нужно перечислить: "sideEffects": ["*.css"].
Ещё мешают: вызовы на верхнем уровне вроде export const theme = createTheme() (остаются, если их не пометить /* @__PURE__ */), конфиг Babel, который превращает import в require, и dev-режим, где tree shaking не делается вовсе.
Хеш вычисляется по содержимому файла, поэтому файл сохраняет свой URL, пока содержимое не изменится, и получает новый, когда оно меняется. Тогда сервер может кэшировать assets/* навсегда, а index.html, единственный файл, который ссылается на актуальные хеши, перепроверяется при каждом визите.
Заголовки: Cache-Control: public, max-age=31536000, immutable для ассетов с хешем и no-cache для index.html. После релиза заново скачиваются только изменённые файлы, а закэшированный index.html оставил бы пользователей на старом релизе.
Импортированные ассеты: import logoUrl from './logo.svg' даёт URL с хешем вроде /assets/logo-<hash>.svg, файлы меньше 4 КБ (build.assetsInlineLimit) встраиваются как data URL, а отсутствующий файл роняет сборку, а не отдаёт 404 в продакшене.
**public/**: копируется как есть в корень dist, без хешей и обработки, и подключается по абсолютному пути. Туда кладут файлы, которым нужно постоянное имя: robots.txt, favicon.ico, веб-манифест, Open Graph-картинку, на которую ссылаются другие сайты.
Ловушка деплоя: вкладка, открытая до релиза, запрашивает чанки, которые новый деплой уже удалил, и получает «Failed to fetch dynamically imported module». Держите ассеты предыдущего релиза на сервере какое-то время и перезагружайте страницу по событию Vite vite:preloadError.
Source map (карта исходного кода) представляет собой файл .map, который сопоставляет каждую позицию в минифицированном бандле с исходным файлом, строкой и именем, чтобы DevTools и трекеры ошибок показывали ваш настоящий код. В продакшене обычно поступают так: генерируют их, загружают в трекер ошибок и не отдают публично.
Как браузер её находит: по комментарию //# sourceMappingURL=app.js.map в конце бандла или по заголовку ответа SourceMap; DevTools скачивает карту, только когда открыт.
Что внутри: mappings (позиции, закодированные в Base64 VLQ), пути sources и обычно sourcesContent, то есть полный исходный код вместе с комментариями.
Публичные карты: кто угодно может прочитать ваш неминифицированный код, комментарии и внутренние имена. Минифицированный код и так публичен, поэтому само по себе это не дыра в безопасности (секрет во фронтенд-коде раскрыт в любом случае), но так проще утечь внутренним деталям и скопировать код.
Скрытые карты: build.sourcemap: 'hidden' в Vite (hidden-source-map в webpack) пишет файлы без комментария; CI загружает их в Sentry или Datadog для этого релиза и удаляет. Стектрейсы становятся читаемыми, а не index-a1b2.js:1:48213, и пользователи карт не получают.
Цена: карты добавляют время сборки и память на больших приложениях, поэтому превью-сборки могут обходиться без них.
Решайте по аналитике ваших пользователей и требованиям бизнеса, запишите решение в виде запроса browserslist и дайте инструментам работать от него: транспайлеры переписывают новый синтаксис под эти браузеры, а полифилы добавляют API, которых в них нет. Один транспайлер недостающую функцию не добавит.
Как решать: реальный трафик по версиям браузеров (Safari на iPhone обновляется только вместе с iOS), клиенты на закрытых корпоративных машинах и байты, которые каждый лишний старый браузер добавляет всем пользователям.
**browserslist** в package.json или .browserslistrc задаёт один список, который читают Babel, Autoprefixer и @vitejs/plugin-legacy: defaults, > 0.5%, last 2 versions, not dead или, начиная с 2025 года, baseline widely available.
Синтаксис (?., ??, поля классов) можно переписать в более старый JavaScript. API (Object.groupBy, structuredClone, Promise.withResolvers) добавляет только полифил, обычно из core-js, который preset-env в Babel подставляет по факту использования при useBuiltIns: 'usage'.
Vite понижает синтаксис до собственного списка build.target (по умолчанию baseline-widely-available: Chrome 111, Firefox 114, Safari 16.4) и полифилов не добавляет. @vitejs/plugin-legacy собирает дополнительный бандл с полифилами для более старых браузеров.
Проверка: eslint-plugin-compat помечает API за пределами ваших целевых браузеров, а реальное устройство с нижней границы списка ловит остальное.
Все три ставят пакеты из реестра npm, а различаются тем, как раскладывают node_modules. npm и Yarn Classic поднимают (hoist) пакеты в одну плоскую папку, а pnpm подключает их ссылками из общего хранилища, поэтому коду доступно только то, что объявлено. Фантомная зависимость означает пакет, который вы импортируете, не объявив его.
Откуда берутся фантомы: при hoisting import debug from 'debug' работает, потому что от него зависит какой-то из ваших пакетов. Когда тот пакет от него откажется или перейдёт на другую мажорную версию, ваша сборка сломается без единого изменения в вашем package.json.
npm: идёт в комплекте с Node, пишет package-lock.json, поднимает пакеты в плоский node_modules, поддерживает workspaces с npm 7.
pnpm: хранит каждую версию один раз в контентно-адресуемом хранилище, жёсткими ссылками подключает её в node_modules/.pnpm и делает симлинки только для ваших прямых зависимостей. Установка быстрая, место на диске общее, фантомные импорты падают, а install-скрипты зависимостей запускаются только после вашего одобрения. pnpm часто выбирают для монорепозиториев.
Yarn: Classic (v1) работает как npm и находится в режиме поддержки; Yarn 4 по умолчанию использует Plug'n'Play, карту разрешения .pnp.cjs вместо node_modules, такую же строгую, как pnpm, но требующую дополнительной настройки для некоторых инструментов.
Один на репозиторий: закрепите его полем packageManager в package.json и коммитьте только его lock-файл. Четвёртый, npm-совместимый вариант даёт Bun с bun.lock.
TypeScript проверяет, что типы сходятся, а ESLint проверяет шаблоны, которые проходят проверку типов, но оказываются багами или плохой практикой: хук, вызванный по условию, пропущенная зависимость эффекта, промис, который никто не ждёт. Prettier только форматирует. У каждого инструмента своя задача, и CI запускает все три отдельными проверками.
ESLint находит: правила хуков React (rules-of-hooks, exhaustive-deps), доступность (jsx-a11y), границы импортов, react-refresh/only-export-components, а с информацией о типах no-floating-promises и no-misused-promises. Правилам с типами нужна программа TypeScript (projectService: true), и работают они медленнее.
TypeScript находит: неверные типы, пропущенные пропсы, необработанный null; линтер их не видит.
Prettier форматирует: он переписывает раскладку кода, поэтому держите стилевые правила вне ESLint (или подключите eslint-config-prettier), а не заставляйте два инструмента спорить о запятых.
В CI: prettier --check ., eslint . --max-warnings=0 и tsc --noEmit (или tsc -b) параллельными джобами; локально интеграция в редактор и lint-staged по изменённым файлам в pre-commit хуке.
Быстрые линтеры: Oxlint (Rust, теперь в шаблонах Vite) и Biome (линтер и форматтер) работают во много раз быстрее; ESLint часто оставляют только ради плагинов, которых у них нет.
ESLint 10 (2026) поддерживает только плоский формат eslint.config.js.
exports задаёт публичную карту точек входа пакета: все пути, которые можно импортировать, и для каждого условия (types, import, require, default) файл, в который путь разрешается; всё, чего нет в списке, импортировать нельзя. Большинство поломок ESM и CommonJS сводится к файлу, реальный формат которого отличается от того, что ждут потребитель или его типы.
Что делает exports
Инкапсуляция: как только появляется exports, import 'pkg/dist/internal.js' падает с ERR_PACKAGE_PATH_NOT_EXPORTED, поэтому добавить его в опубликованный пакет значит сделать ломающее изменение.
Условия проверяются в порядке ключей объекта, и побеждает первое совпадение: ставьте types первым, а default последним. Бандлеры добавляют свои условия, например browser и development.
В Node и в современных бандлерах оно важнее, чем main и module.
Почему импорты ломаются
**Формат определяется расширением и полем "type"**: при "type": "module" каждый .js-файл считается ESM, поэтому CommonJS-сборка, сохранённая как .js, падает с «require is not defined in ES module scope»; ей нужно расширение .cjs.
ESM, импортирующий CommonJS, получает module.exports как экспорт по умолчанию; именованные импорты работают, только если Node может найти их статически, иначе «Named export 'x' not found».
**CommonJS, который делает require ESM-модуля**, падал с ERR_REQUIRE_ESM, пока Node 20.19 и 22.12 не включили require(esm); модуль с await на верхнем уровне по-прежнему нельзя загрузить через require.
Dual package hazard: пакет, который поставляет оба формата, может загрузиться в одном приложении дважды, по разу на формат, с двумя копиями своего состояния, и тогда контекст или проверка instanceof перестают совпадать.
Типы: TypeScript читает exports, только если moduleResolution равен bundler, node16 или nodenext, а ESM-типы, отданные для CommonJS-файла, неправильно описывают его импорт по умолчанию.
Что помогает
Дайте каждому формату свои типы (.d.ts и .d.cts), как в примере, и проверяйте пакет через publint и @arethetypeswrong/cli перед публикацией.
Многие новые пакеты выходят только в ESM, раз каждая поддерживаемая версия Node умеет делать для них require().
Сначала измерьте, а потом меняйте: засеките каждый шаг отдельно и профилируйте бандлер. Обычно причиной оказывается медленный трансформ на каждом файле (часто Babel), проверка типов или линтинг внутри сборки, огромные barrel-импорты, устаревший тулчейн или холодный кэш в CI.
Найти причину
Разделите пайплайн: запустите tsc --noEmit, линтер и бандлер по отдельности и засеките каждый. «Десятиминутная сборка» часто оказывается проверкой типов или линтером в виде плагина (fork-ts-checker-webpack-plugin, vite-plugin-checker), который работает внутри неё.
Vite: vite --profile записывает .cpuprofile, который открывается в speedscope, vite --debug plugin-transform показывает время по плагинам и файлам, а vite-plugin-inspect показывает, что делает каждый плагин.
webpack: webpack --profile --json > stats.json или Rsdoctor показывают время по каждому лоадеру и плагину.
Медленный старт дев-сервера: «new dependencies optimized» в логе и следом перезагрузка означают, что зависимости нашлись поздно и вызвали повторную оптимизацию; перечислите их в optimizeDeps.include.
Исправить
Нативные трансформы: swc-loader или esbuild-loader вместо babel-loader, @vitejs/plugin-react v6 (Oxc) или plugin-react-swc; оставшиеся плагины Babel, например React Compiler, ограничьте файлами, которым они нужны.
Меньше модулей: импортируйте напрямую, а не через большие barrel-файлы (один barrel набора иконок может означать тысячи модулей), и прогревайте часто нужные файлы через server.warmup.
Кэши: cache: { type: 'filesystem' } в webpack 5, tsc -b с его .tsbuildinfo, а в CI восстанавливайте хранилище пакетного менеджера и эти кэши между запусками.
Обновиться: Vite 8 с Rolldown, Rspack вместо webpack, Turbopack (по умолчанию в Next.js 16), нативный компилятор TypeScript 7 для проверки типов.
Структура CI: проверка типов, линтинг, тесты и сборка параллельными джобами; в монорепозитории собирайте только затронутые пакеты (Turborepo или Nx с удалённым кэшем). Отслеживайте время сборки как метрику CI, чтобы оно не росло снова.
Относитесь к каждой зависимости как к коду, который могут перехватить: ставьте только то, что закреплено в lock-файле, не торопитесь брать только что вышедшие версии, блокируйте install-скрипты, держите секреты вне досягаемости установки и не раздувайте список зависимостей. Одной меры недостаточно.
Что показал 2025 год
В сентябре через фишинг угнали аккаунт мейнтейнера и опубликовали вредоносные версии chalk, debug и ещё 16 пакетов. Код перехватывал fetch и API криптокошельков в браузере, чтобы подменять адреса платежей, поэтому приложения, собранные в эти часы, отправили его своим пользователям.
Червь Shai-Hulud (сентябрь, вторая волна в ноябре) запускался из install-скриптов, крал токены npm, GitHub и облаков с машин разработчиков и из CI и публиковал с их помощью заражённые версии других пакетов.
После этого npm отозвал все классические токены и перевёл публикацию на короткоживущие токены, 2FA и trusted publishing из CI.
На этапе установки
Только lock-файл: npm ci или pnpm install --frozen-lockfile в CI, и диф lock-файла читают на ревью: неожиданный новый пакет или изменившийся URL в resolved служат тревожным сигналом.
Выдержка: вредоносные версии обычно ловят за часы или дни, поэтому пропускайте версии моложе этого срока: minimumReleaseAge в pnpm (сутки по умолчанию начиная с pnpm 11), min-release-age в npm (в днях, npm 11.10+), cooldown в Renovate или Dependabot.
Install-скрипты выключены по умолчанию: pnpm запускает скрипты зависимостей только для одобренных пакетов (allowBuilds); npm 12 блокирует их, пока пакет не указан в allowScripts корневого package.json; в npm 11 задайте ignore-scripts=true.
Ограничить ущерб
Секреты: никаких токенов публикации и облачных ключей на шаге установки, короткоживущие OIDC-креды в CI, никакого долгоживущего токена в ~/.npmrc.
Обнаружение: npm audit знает только о зарегистрированных CVE; сканеры поведения вроде Socket замечают в обновлении новый install-скрипт, обфусцированный код или сетевые обращения.
Меньше пакетов: каждая зависимость, как и аккаунт её мейнтейнера, входит в вашу цепочку доверия.
В браузере: строгий CSP с connect-src ограничивает, куда внедрённый код может отправить данные, а Subresource Integrity закрепляет скрипты, загружаемые с CDN.