Полируем clicktone

03 Июл 2026

После изменений в Web Audio поведение clicktone меня устраивает гораздо больше, чем инфраструктура вокруг пакета.

Здесь есть неприятный класс ошибок: исходники проходят юнит-тесты и проверку типов, сборка завершается успешно, а опубликованный пакет всё равно не работает при одном из способов импорта. Пользователь ведь устанавливает не src. Он получает конкретный архив с JavaScript, декларациями типов и package.json.

В опубликованном 2.1.0 такая проблема была вполне конкретной: index.d.ts ссылался на отсутствующий main.d.ts, а CommonJS-файл назывался index.cjs.js внутри пакета с type: module. Проверки исходников не подтверждали корректность этих файлов и путей.

В 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 недостаточно: в обеих ветках exports должны быть правильно указаны пути к JavaScript и декларациям типов. Эти пути тоже входят в контракт пакета.

Публичный ./dist/* я убрал. Внутренние файлы не стоит случайно превращать во внешний API только потому, что они лежат в опубликованной папке.

Состав пакета тоже ограничил явно:

{
  "files": ["dist", "README.md", "LICENSE"]
}

Исходники, тесты и локальные конфиги остаются в репозитории. В состав npm-пакета я их не включаю.

Smoke test для dist

Юнит-тесты clicktone проверяют поведение исходного кода. После сборки нужно проверить, появились ли ожидаемые файлы и можно ли загрузить модули ESM и CJS.

Для этого оставил отдельный 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 добавляет к опубликованному пакету информацию о происхождении сборки.

Релиз перестал быть просто командой после удачной сборки.

Материалы