Полируем clicktone
03 Июл 2026
После изменений в Web Audio поведение clicktone меня устраивает гораздо больше, чем инфраструктура вокруг пакета.
Здесь есть неприятный класс ошибок: исходники проходят юнит-тесты и проверку типов, сборка завершается успешно, а опубликованный пакет всё равно оказывается сломан для одного из способов импорта. Пользователь ведь устанавливает не src. Он получает конкретный архив с JavaScript, декларациями типов и package.json.
В 3.0.x API воспроизведения почти не менял. Вместо этого хочу сделать проверяемым именно тот артефакт, который уходит в npm.
Убрать одинаковые правила из репозитория
До этого clicktone собирался в режиме библиотеки Vite. Локальный конфиг описывал ESM, CommonJS и выходы UMD, подключал vite-plugin-dts, задавал имена файлов и часть настроек Rollup.
Для одной библиотеки такая конфигурация вполне терпима. Когда пакетов несколько, одинаковые правила начинают копироваться между репозиториями и понемногу расходиться.
В 3.0.0 сборка переехала на tsdown, а общие настройки — в отдельные пакеты конфигов. Локальный конфиг сборки после этого стал коротким:
import { defineLibrary } from '@ux-ui/tsdown-config';
export default defineLibrary({
platform: 'browser',
entry: { index: 'src/main.ts' },
});
То же самое с TypeScript и Biome:
{
"extends": "@ux-ui/tsconfig-base/tsconfig.json",
"include": ["src", "tsdown.config.ts", "vitest.config.ts"]
}
{
"extends": ["@ux-ui/biome-config/biome"]
}
Смысл здесь не в замене одного бандлера другим. Из clicktone исчезают правила, которые вообще не относятся к его звуковому API. Если настройка одинакова для нескольких npm-библиотек, поддерживать её удобнее в одном месте.
package.json должен совпадать со сборкой
После унификации сборки я заодно сузил публичную поверхность пакета.
Основная точка входа в 3.0.0 описана так:
{
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
Для import есть ESM-код и соответствующая декларация типов. Для require — CJS-код и отдельный .d.cts.
Это та часть пакета, которую легко недооценить, если проверять только факт генерации .d.ts. TypeScript и Node идут по разным веткам exports, поэтому сами пути тоже являются частью контракта.
Публичный ./dist/* я убрал. Внутренние файлы не стоит случайно превращать во внешний API только потому, что они лежат в опубликованной папке.
Состав пакета тоже ограничил явно:
{
"files": ["dist", "README.md", "LICENSE"]
}
Исходники, тесты и локальные конфиги остаются в репозитории, но пользователю библиотеки они не нужны.
Smoke test уже по dist
Юнит-тесты clicktone проверяют поведение исходного кода. После сборки появляется другой набор вопросов: создались ли нужные файлы и можно ли реально открыть обе точки входа.
Для этого оставил отдельный smoke test поверх dist:
const expectedArtifacts = [
'dist/index.js',
'dist/index.cjs',
'dist/index.d.ts',
'dist/index.d.cts',
];
for (const artifact of expectedArtifacts) {
assert.equal(existsSync(artifact), true);
}
const esm = await import('../dist/index.js');
assert.equal(typeof esm.ClickTone, 'function');
const cjs = await import('../dist/index.cjs');
assert.equal(typeof cjs.ClickTone, 'function');
Он нарочно простой. Повторять здесь все юнит-тесты не нужно. Smoke test отвечает только за собранный слой: ожидаемые файлы существуют, ESM открывается, CJS открывается.
Но локальный dist — ещё не npm-пакет
Даже после этого можно ошибиться в exports, files или декларациях типов и не заметить проблему на локальном импорте.
Поэтому verify заканчивается проверкой будущего пакета:
{
"verify": "npm run lint && npm run typecheck && npm run test:unit && npm run build && npm run test:smoke && npm run pack:check",
"pack:check": "npm pack --dry-run && publint && attw --pack . --profile node16"
}
npm pack --dry-run показывает состав будущего архива пакета. publint проверяет метаданные пакета и совместимость точек входа. attw смотрит, как опубликованный пакет виден TypeScript при разных вариантах разрешения модулей.
Получилась довольно понятная последовательность:
source
↓
build
↓
dist imports
↓
package metadata / types resolution
Зелёной сборки теперь недостаточно. Перед публикацией должен пройти весь путь до контракта пакета.
Та же команда в CI
Отдельно дублировать эти проверки в GitHub Actions не хотелось. Workflow запускает тот же npm run verify, который можно прогнать локально:
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run verify
Так у локальной разработки и CI нет двух похожих, но разных наборов требований. Если меняется проверка пакета, она меняется в одном скрипте, а workflow просто запускает его на поддерживаемых версиях Node.
Публикация без постоянного токена npm
Публикацию тоже перенёс в GitHub Actions. Workflow запускается после GitHub Release, ещё раз выполняет verify и затем публикует пакет:
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm install -g npm@latest
- run: npm ci
- run: npm run verify
- run: npm publish --provenance --access public
Для доступа к npm используется Trusted Publishing через OIDC, поэтому долгоживущий NPM_TOKEN в секретах GitHub больше не нужен. --provenance добавляет к опубликованному пакету информацию о происхождении сборки.
Релиз перестал быть просто командой после удачной сборки. Теперь перед npm publish отдельно проверяется то, что действительно увидит пользователь пакета: обе точки входа, декларации типов, метаданные и содержимое архива.