Cordis и плагинная архитектура в TypeScript без головной боли
Если вы когда-нибудь писали расширяемое приложение на Node.js или TypeScript, вы наверняка наступали на одни и те же грабли. Пользователь или система подключает плагин. Плагин вешает пяток слушателей событий, запускает пару таймеров setInterval, регистрирует свои роуты и внедряет сервис. А потом плагин отключают или обновляют на лету.
Что происходит дальше? Правильно, утечки памяти. Слушатели висят, таймеры тикают в фоне, ссылки на контекст не дают сборщику мусора очистить память. В Node.js управление жизненным циклом зависимостей часто превращается в ручную рутину.
Некоторое время назад разработчики чат-бот фреймворка Koishi столкнулись ровно с этой задачей. Им требовалось создать ядро, в котором сотни сторонних плагинов могут запускаться, изолироваться, заменять друг друга и выгружаться без перезапуска процесса. Так появился фреймворк Cordis.
Что вообще такое Cordis
Создатели называют свой проект «meta-framework of spatiotemporal composability». Звучит заумно и претенциозно, но суть на деле приземленная.
Cordis объединяет контейнер внедрения зависимостей (IoC), шину событий и иерархическое дерево контекстов. Каждый плагин или сервис живет внутри своего контекста. Если этот контекст уничтожается, Cordis автоматически подчищает за ним абсолютно все ресурсы: снимает обработчики событий, глушит таймеры и удаляет созданные сервисы.
import { Context } from 'cordis'
// Создаем корневой контекст приложения
const ctx = new Context()
// Регистрируем плагин
ctx.plugin((ctx) => {
ctx.on('ready', () => {
console.log('Плагин готов к работе')
})
// Этот интервал сам остановится при выгрузке плагина
ctx.setInterval(() => {
console.log('Тик таймера')
}, 1000)
})
await ctx.start()
Здесь нет магии, но есть четкая дисциплина: если плагин использует методы ctx.on, ctx.setInterval или ctx.effect, фреймворк берет очистку сайд-эффектов на себя.
Как устроена модель контекстов
Центральное понятие в библиотеке — Context. Это не просто плоский объект с настройками, а ветвящееся дерево.
Когда вы вызываете ctx.plugin(), фреймворк порождает дочерний контекст (fork). Дочерний контекст наследует сервисы родителя, но хранит собственные ссылки на зарегистрированные ресурсы.
Root Context
├── Database Service (глобальный)
├── Plugin A (Fork Context)
│ └── Event Listener: 'user-login'
└── Plugin B (Fork Context)
└── HTTP Route: '/api/status'
Если вы отключите Plugin A, его дочерний контекст схлопнется. Обработчик user-login снимется из общей шины событий, при этом Database Service и Plugin B продолжат спокойно работать.
Сервисы и типизация в TypeScript
Сервисы в Cordis объявляются через наследование базового класса Service. Это делает их доступными прямо через свойства контекста с сохранением строгой типизации:
import { Context, Service } from 'cordis'
// Расширяем интерфейс контекста для автокомплита в IDE
declare module 'cordis' {
interface Context {
database: DatabaseService
}
}
class DatabaseService extends Service {
constructor(ctx: Context) {
// Регистрируем сервис под именем 'database'
super(ctx, 'database', true)
}
getUser(id: string) {
return { id, name: 'Alex' }
}
}
const ctx = new Context()
ctx.plugin(DatabaseService)
// В другом плагине можно объявить зависимость от сервиса:
ctx.plugin((ctx) => {
// Код выполнится только тогда, когда сервис database загружен
ctx.inject(['database'], (ctx) => {
const user = ctx.database.getUser('123')
console.log(user)
})
})
Конструкция ctx.inject решает проблему порядка загрузки модулей. Если сервис базы данных инициализируется асинхронно или подключается позже, зависимый плагин дождется его готовности и активируется сам.
Тонкая настройка областей видимости
В реальных программах модули часто не должны реагировать на все подряд. Скажем, один обработчик нужен только для сообщений из определенного канала или запросов с конкретным заголовком.
Cordis вводит концепцию фильтров через вызов ctx.isolate() и свойства контекста. Вы можете ограничить видимость сервиса отдельной веткой дерева или задать предикат, отсекающий ненужные события:
// Создаем изолированную ветку для конкретной сессии
const sessionCtx = ctx.isolate('session')
sessionCtx.plugin((ctx) => {
// Этот плагин не будет конфликтовать с сервисами других сессий
})
Для каких задач это подходит
Библиотека создавалась под специфический класс приложений. Не стоит тащить ее в обычный CRUD API на Fastify или Express, там она создаст лишний слой абстракции.
Зато Cordis отлично вписывается в следующие сценарии:
- Модульные CLI-утилиты и генераторы. Когда пользователи могут доставлять npm-пакеты, расширяющие команды или пайплайны сборки.
- Десктопные приложения на Electron/Tauri. Для организации системы аддонов и тем оформления, которые можно включать и отключать на лету без перезагрузки окна.
- Боты и интеграционные хабы. Если сервис общается с десятком разных платформ (Telegram, Discord, Slack), и каждый адаптер должен жить изолированной жизнью.
- Инструменты автоматизации. Где процессы конфигурируются пользователями динамически через веб-интерфейс или YAML-файлы.
Подводные камни и недостатки
Идеальных инструментов нет, и у Cordis хватает специфических нюансов:
- Крутая кривая обучения. Документация написана сухим языком с обилием специфических терминов. Чтобы продраться сквозь концепции слияния скоупов и сайд-эффектов, придется внимательно почитать исходники.
- Непривычный API. Привязка сервисов к объекту контекста через модуль мерджинга типов TypeScript может сначала сбивать с толку тех, кто привык к классическому NestJS или InversifyJS с их декораторами.
- Привязка к ментальной модели. Если архитектура вашего проекта не предполагает частой динамической выгрузки кода, выгоды от встроенного менеджера эффектов нивелируются усложнением кода.
Стоит ли пробовать
Cordis — интересный инженерный проект с фокусом на управление жизненным циклом компонентов. Он элегантно закрывает вопрос утечек ресурсов при создании расширяемых систем.
Если вы проектируете систему с развитой экосистемой подключаемых плагинов и хотите получить надежный механизм отслеживания сайд-эффектов из коробки, форкните репозиторий cordiverse/cordis и изучите примеры в тестах. Это отличный образец того, как можно выстроить микроядерную архитектуру на чистом TypeScript.
