Все посты

Утиная типизация для плагинов: почему мы убили интерфейс IPlugin

12 марта 2026 г.6 мин чтенияKB Labs
Share

Большинство плагин-систем начинаются одинаково: объявляется интерфейс IPlugin, все его реализуют, типовая система следит за контрактом. Чисто, привычно — и создаёт зависимость, которая будет преследовать вас ещё долго.

В KB Labs нет интерфейса плагина. Вместо этого — утиная типизация: если у пакета есть манифест с schema: 'kb.plugin/3', он плагин. Без базового класса. Без implements. Без вызова регистрации в рантайме.

Проблема с интерфейсами

Интерфейс IPlugin связывает каждый плагин с ядром на этапе компиляции. Это означает:

  • Каждый плагин импортирует ядро (или общий пакет, реэкспортирующий интерфейс)
  • Изменение интерфейса — это одновременно ломающее изменение для всех плагинов сразу
  • Интерфейс должен быть объединением всех возможных возможностей плагина — команды, роуты, вебхуки, крон, Studio-страницы — что делает его либо огромным, либо бесполезным
  • Циклические зависимости неизбежны, когда ядру нужно загружать плагины, зависящие от ядра

Мы через это прошли. Интерфейс вырос до 14 опциональных методов. Плагины реализовывали 2–3 из них, остальные — заглушки. Тип-система была довольна; дизайн — нет.

Что мы делаем вместо

Плагин — это любой npm-пакет, чей манифест объявляет schema: 'kb.plugin/3'. Манифест — простой объект, не экземпляр класса, не возврат фабричной функции.

// plugins/commit/entry/src/manifest.ts
export const manifest = {
  schema: 'kb.plugin/3',
  id: '@kb-labs/commit',
  version: '1.0.0',
  display: { name: 'Commit Generator', description: '...' },
  permissions: combinePermissions()
    .with(gitWorkflowPreset)
    .with(kbPlatformPreset)
    .build(),
  cli: {
    commands: [
      { name: 'commit', handler: './dist/commands/commit.js' }
    ]
  }
};

Возможности объявляются как секции. Плагин с CLI-командами имеет секцию cli. Плагин с REST-роутами — секцию rest. Плагин с воркфлоу-хендлерами — секцию workflows. Объявляешь только то, что используешь:

interface ManifestV3 {
  schema: 'kb.plugin/3';
  id: string;
  version: string;
  display?: DisplayMetadata;
  permissions?: PermissionSpec;
  cli?: { commands: CliCommandDecl[] };
  rest?: RestConfig;
  ws?: WebSocketConfig;
  workflows?: { handlers: WorkflowHandlerDecl[] };
  webhooks?: { handlers: WebhookHandlerDecl[] };
  jobs?: JobsConfig;
  cron?: { schedules: CronDecl[] };
  studio?: StudioConfig;
  lifecycle?: LifecycleHooks;
}

Как работает дискавери без интерфейса

DiscoveryManager в core-discovery читает файл marketplace.lock — единственный источник истины об установленных плагинах. Для каждой записи манифест разрешается через три пути с фолбэком:

  1. Поле kb.manifest в package.json
  2. Файл kb.plugin.json в корне пакета
  3. Дефолтный экспорт dist/index.js

Манифест валидируется через тайп-гард (isManifestV3), возможности извлекаются проверкой наличия секций. Плагин с секциями cli и workflows регистрируется одновременно как поставщик CLI-команд и воркфлоу-хендлеров — автоматически, без явного вызова регистрации.

Что получилось

  • Нулевая связь с ядром. Единственная compile-time зависимость плагина — @kb-labs/sdk (типы и хелперы). Ядро рантайма он никогда не импортирует.
  • Аддитивная эволюция. Добавить новую возможность (например, cron) — значит добавить новую опциональную секцию в ManifestV3. Существующие плагины ничего не меняют.
  • Никаких церемоний регистрации. Установи пакет, добавь в lock-файл, перезапусти. Платформа найдёт сама.
  • Тестирование в изоляции. Манифест — простой объект. Можно юнит-тестировать плагин без запуска всей платформы.

Утиная типизация — не универсальный паттерн. Но для плагин-систем, где ядро должно быть максимально стабильным, а расширения — максимально свободными, это именно тот паттерн, который позволяет обеим сторонам эволюционировать не ломая друг друга.

Утиная типизация для плагинов: почему мы убили интерфейс IPlugin