Делаем API Iconly предсказуемым

25 Янв 2026

В 2.0 в основном приводил в порядок внутреннее устройство iconly, сохраняя прежний способ использования. Для следующего мажорного релиза задача другая. Теперь я хочу пересобрать сам публичный контракт так, чтобы по нему было проще понять, что произошло во время инициализации, подменить отдельные части и нормально тестировать библиотеку.

Заодно это часть более общей работы по унификации моих библиотек. Мне нужна не просто работающая утилита с собственными привычками, а более одинаковый подход к API: явные результаты операций, небольшая публичная поверхность и заменяемые зависимости там, где это действительно полезно.

В 3.0.0 поменялось сразу несколько вещей. new Iconly() уступил место createIconly(), init() возвращает Result, хранилище становится отдельным интерфейсом, а монолитный исходник разбивается на ядро, работу с DOM, загрузку, ошибки и адаптеры хранилища.

Одного Promise<void> уже мало

В 2.0.1 вызов выглядит просто:

import Iconly from 'iconly';

const iconly = new Iconly({
  file: './sprite.svg',
  version: '1.0',
});

await iconly.init();

Но из самого результата вызова невозможно понять, загрузился ли спрайт. init() возвращает Promise<void>, а ошибки выполнения обрабатываются внутри библиотеки. Для небольшой утилиты этого достаточно, но такой контракт плохо масштабируется. Я хочу явно различать ошибку контейнера, загрузки, хранилища или разбора SVG и при этом не заставлять пользователя угадывать поведение по выводу в консоль.

В 3.0 результат становится частью API:

import { createIconly } from 'iconly';

const iconLoader = createIconly({
  file: './sprite.svg',
  version: '1.0',
});

const result = await iconLoader.init();

if (!result.ok) {
  console.error(result.error);
}

Сам тип очень небольшой:

export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: IconlyError };

Для init() это фактически Promise<Result<void>>. У вызывающего кода теперь есть два явных состояния, и TypeScript различает их по ok.

Ошибка тоже становится данными

Одного ok: false мало, если дальше всё равно приходится разбирать текст сообщения. Поэтому у ошибки появляется собственная форма:

export interface IconlyError {
  code: IconlyErrorCode;
  message: string;
  cause?: unknown;
}

В текущем релизе коды описывают конкретные границы операции:

type IconlyErrorCode =
  | 'container_invalid'
  | 'fetch_aborted'
  | 'fetch_failed'
  | 'indexeddb_not_supported'
  | 'indexeddb_open_failed'
  | 'indexeddb_request_failed'
  | 'parse_error'
  | 'storage_read_failed'
  | 'storage_unavailable'
  | 'storage_write_failed';

Теперь код потребителя может реагировать на категорию ошибки, а не на конкретную формулировку текста:

const result = await iconLoader.init();

if (!result.ok && result.error.code === 'fetch_aborted') {
  // запрос был отменён
}

Тот же Result проходит через внутренние части библиотеки. fetchSvg() возвращает Result<string>, вставка SVG возвращает Result<void>, адаптеры хранилища возвращают Result из get() и set().

Это позволяет ядру собирать одну последовательность операций без смеси исключений, null, логических флагов и скрытого логирования.

В README контракт init() сформулирован прямо: штатные ошибки операции не выбрасываются исключением, а возвращаются через Result, который нужно проверить.

Логирование больше не единственный канал ошибок

В предыдущей версии отладочный вывод был частью внутреннего поведения класса. Теперь диагностику тоже можно явно подключить к приложению:

const iconLoader = createIconly({
  onError: (error) => reportError(error),
  onDebug: (...messages) => debugLog(...messages),
  logger: {
    debug: (...messages) => console.debug(...messages),
    error: (...messages) => console.error(...messages),
  },
});

При этом колбэки не заменяют Result. Ошибка передаётся в onError и logger.error, но init() всё равно возвращает тот же объект ошибки вызывающему коду.

debug управляет только отладочными сообщениями. Ошибки остаются отдельным каналом и не исчезают из-за debug: false.

Для меня это важное разделение. Результат операции нужен программе. logger и колбэки нужны для диагностики вокруг неё. Это не одна и та же задача.

Фабричная функция вместо публичного класса

Вместе с новым контрактом ошибок я убираю публичный конструктор:

const iconLoader = createIconly(config);

Фабричная функция возвращает небольшой объект:

export interface IconlyInstance {
  init: () => Promise<Result<void>>;
  abort: () => void;
}

Мне здесь важна не сама замена синтаксиса new на функцию. Публичный класс заставляет считать устройство класса частью API. В новой версии потребителю важны только доступные операции экземпляра.

Это лучше укладывается в стиль публичных API, к которому я привожу свои библиотеки: меньше обязательной формы реализации и больше явных контрактов через типы.

Второй метод, abort(), нужен для активного fetch. Внутри createIconly() хранится AbortController, а отменённый запрос превращается в обычный результат с кодом fetch_aborted:

const iconLoader = createIconly({ file: './sprite.svg' });

const pending = iconLoader.init();
iconLoader.abort();

const result = await pending;

То есть отмена тоже получает место в том же контракте ошибок, а не отдельную случайную ветку поведения.

IndexedDB больше не должен быть самим ядром

В ранних версиях iconly хранилище и основная логика были почти одним целым. С 3.0 я хочу оставить IndexedDB вариантом по умолчанию, но перестать делать его обязательной формой внутреннего устройства.

Для этого появился публичный интерфейс:

export interface IconStorage {
  get(version: string): Promise<Result<IconRecord | undefined>>;
  set(record: IconRecord): Promise<Result<void>>;
}

А опция storage принимает несколько стратегий:

export type StorageStrategy =
  | 'indexeddb'
  | 'memory'
  | 'session'
  | IconStorage;

Встроенных вариантов три:

createIconly({ storage: 'indexeddb' });
createIconly({ storage: 'memory' });
createIconly({ storage: 'session' });

Четвёртый вариант — собственная реализация IconStorage.

Здесь для меня особенно важна тестируемость. Ядро теперь общается с get() и set(), а не знает детали каждой реализации. Для обычного теста можно выбрать хранилище в памяти и не поднимать IndexedDB вообще. Там, где нужно проверить именно IndexedDB, можно тестировать эту стратегию отдельно в контролируемой среде.

Хранилище заодно исправляет порядок работы кэша

До 3.0 IndexedDB уже использовался, но сама последовательность была неидеальной: iconly сначала выполнял fetch(), а потом читал сохранённую запись. Получалось, что кэш не мог убрать сетевой запрос.

После разделения хранилища порядок становится естественнее:

const cacheResult = await storage.get(resolved.version);
let data = cacheResult.value?.data;

if (!data) {
  const fetchResult = await fetchSvg(resolved.file, controller.signal);
  data = fetchResult.value;

  await storage.set({
    version: resolved.version,
    data,
  });
}

Сначала читается хранилище. Только если записи нет, выполняется fetch.

Это не просто архитектурная перестановка. Поведение кэша реально меняется: повторная инициализация с той же версией может обойтись без сети.

Закрепил это тестом. Первый экземпляр загружает /sprite.svg и записывает данные в IndexedDB. Второй использует ту же базу и ту же версию. Оба init() завершаются успешно, но подменённый fetch должен остаться вызванным только один раз.

expect(firstResult.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);

const secondResult = await second.init();

expect(secondResult.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);

Для предыдущей версии я не мог бы честно описать IndexedDB как способ избежать повторного сетевого запроса. Начиная с 3.0, это уже соответствует коду и тесту.

Тесты становятся частью самой архитектуры

В 3.0 у пакета появляется набор тестов на Vitest, jsdom и fake-indexeddb.

Простой DOM-сценарий можно проверить через хранилище в памяти:

const iconly = createIconly({
  storage: 'memory',
  file: '/sprite.svg',
  version: '1.0',
  container,
});

const result = await iconly.init();

expect(result.ok).toBe(true);
expect(container.querySelector('[data-iconly="iconset"] svg')).not.toBeNull();

Для IndexedDB отдельный тест использует fake-indexeddb и проверяет чтение из кэша.

Именно здесь понятно, зачем мне понадобилось разрезать библиотеку по границам. Это не абстракции ради количества файлов. Возможность выбрать хранилище в памяти позволяет тестировать основной сценарий без IndexedDB, а контракт Result позволяет проверять исход операции напрямую.

В package.json заодно появилась общая команда проверки:

{
  "verify": "yarn lint && yarn typecheck && yarn test"
}

Превращение одного файла в несколько понятных границ

До этого основная реализация жила в src/index.ts. В 3.0 структура становится такой:

src/
  core.ts
  dom.ts
  errors.ts
  fetcher.ts
  index.ts
  result.ts
  storage/
    index.ts
    indexeddb.ts
    memory.ts
    session.ts
  types.ts

index.ts теперь в основном задаёт публичные экспорты. core.ts оркестрирует процесс. Вставка в DOM, загрузка, вспомогательные функции Result и разные реализации хранилища находятся отдельно.

Само количество файлов ничего не гарантирует. Для меня ценность в другом: эти границы совпадают с контрактами, которые появились в коде. Хранилище действительно можно заменить. Загрузка и DOM действительно возвращают тот же Result. Публичные типы действительно экспортируются из одной точки.

Такую структуру проще проверять и проще менять.

Мажорный релиз всё равно требует миграции

Предсказуемый API не означает полностью совместимый API. В 3.0 есть несколько намеренных несовместимых изменений.

Главный — конструктор заменён фабричной функцией, и результат init() теперь нужно проверять:

// 2.x
const iconly = new Iconly(options);
await iconly.init();

// 3.x
const iconly = createIconly(options);
const result = await iconly.init();

if (!result.ok) {
  // обработать result.error
}

Поменялся и DOM-якорь. Вместо глобального #iconset обёртка создаётся внутри выбранного контейнера:

<div data-iconly="iconset" aria-hidden="true">...</div>

Это тоже часть нового контракта. Внутренний селектор больше не опирается на глобальный id, а обёртка явно помечена как служебная и скрыта от дерева доступности.