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

Материалы