@gramio/composer
@gramio/composer — универсальная типобезопасная библиотека композиции middleware, на которой строятся внутренности GramIO. Если вы пишете плагин, строите фреймворк поверх GramIO или просто хотите понять, как работает обогащение контекста — добро пожаловать.
Установка
npm install @gramio/composeryarn add @gramio/composerpnpm add @gramio/composerbun add @gramio/composerОсновные концепции
Composer — цепочечный пайплайн middleware. Каждый метод регистрирует новый шаг и возвращает обновлённый композер для цепочки:
import { Composer } from "@gramio/composer";
const app = new Composer<{ request: Request }>()
.use(logger)
.derive(fetchUser)
.guard(isAuthenticated)
.use(handler);use()
Регистрирует middleware напрямую. Обработчик получает (context, next) и должен вызвать next() для продолжения цепочки:
app.use(async (ctx, next) => {
console.log("до");
await next();
console.log("после");
});derive()
Обогащает контекст вычисленными значениями. Возвращённый объект мержится в контекст для всех последующих middleware:
app.derive(async (ctx) => {
const user = await db.findUser(ctx.userId);
return { user };
});
// ctx.user теперь доступен ниже по цепочкеdecorate()
Как derive(), но для статических значений без вычислений на каждый запрос. Присваивает объект один раз при регистрации и переиспользует ту же ссылку — нулевые накладные расходы:
app.decorate({ db: myDatabase, config: appConfig });
// ctx.db и ctx.config доступны на каждый запрос без оверхедаПоддерживает { as: "scoped" } или { as: "global" } для распространения через extend().
guard()
Продолжает цепочку только если предикат возвращает true:
app.guard((ctx) => ctx.user.isAdmin);
// Последующие middleware работают только для администраторовwhen()
Условная регистрация middleware на этапе запуска. Условие вычисляется один раз при старте приложения, не на каждый запрос. Свойства из блока типизируются как Partial (опциональные):
const app = new Composer()
.when(process.env.NODE_ENV !== "production", (c) =>
c.use(verboseLogger)
)
.when(config.features.analytics, (c) =>
c.derive(() => ({ analytics: createAnalyticsClient() }))
);Отличие от branch():
when()— условие вычисляется один раз при старте (build-time)branch()— условие вычисляется на каждый запрос (runtime)
Вложенные when() работают. Ключи дедупликации, обработчики и определения ошибок пробрасываются из условного блока.
Наблюдаемость
inspect()
Возвращает snapshot всех зарегистрированных middleware с метаданными:
const app = new Composer()
.derive(function getUser() { return { user: "alice" }; })
.guard(function isAdmin() { return true; })
.use(async function handleRequest(_, next) { return next(); });
app.inspect();
// [
// { index: 0, type: "derive", name: "getUser", scope: "local" },
// { index: 1, type: "guard", name: "isAdmin", scope: "local" },
// { index: 2, type: "use", name: "handleRequest", scope: "local" },
// ]При расширении именованного плагина поле plugin показывает источник:
const auth = new Composer({ name: "auth" })
.derive(function getUser() { return { user: "alice" }; })
.as("scoped");
new Composer().extend(auth).inspect();
// [{ index: 0, type: "derive", name: "getUser", scope: "local", plugin: "auth" }]trace()
Opt-in-инструментация каждого middleware. Нулевые накладные расходы без трассировки:
app.trace((entry, ctx) => {
const span = tracer.startSpan(`${entry.type}:${entry.name ?? "anonymous"}`);
span.setAttributes({
"middleware.index": entry.index,
"middleware.scope": entry.scope,
...(entry.plugin && { "middleware.plugin": entry.plugin }),
});
return (error) => {
if (error) span.recordException(error as Error);
span.end();
};
});Жизненный цикл TraceHandler:
- Вызывается перед каждым middleware с
MiddlewareInfoи контекстом - Может возвращать функцию очистки
(error?: unknown) => void - Очистка вызывается после завершения middleware (без аргументов при успехе, с ошибкой при сбое)
- Ошибки продолжают проброс в
onErrorпосле очистки
Система скоупов
Скоупы контролируют как middleware распространяется при расширении одного композера другим:
| Скоуп | Поведение |
|---|---|
"local" (по умолчанию) | Изолирован в обёртке — контекст не утекает в родителя |
"scoped" | Добавляется в родителя как локальная запись — видно последующим middleware родителя |
"global" | Добавляется в родителя как глобальный — продолжает распространяться через дальнейшие extend() |
Переведите весь композер в нужный скоуп через .as():
const plugin = new Composer({ name: "auth" })
.derive(function getUser() { return { user: "alice" }; })
.as("scoped"); // весь плагин становится scoped
app.extend(plugin); // getUser виден в downstream-middleware appОбработка ошибок
class NotFoundError extends Error {}
const app = new Composer()
.error("NotFound", NotFoundError)
.onError(({ error, kind, context }) => {
if (kind === "NotFound") {
context.send("Ресурс не найден");
return "handled";
}
})
.use(() => { throw new NotFoundError("Элемент не найден"); });Несколько обработчиков onError() проверяются по очереди — первый вернувший не-undefined побеждает. Необработанные ошибки логируются через console.error.
Разработка плагинов
Для авторов плагинов @gramio/composer — фундамент класса Plugin в GramIO. Концепции напрямую соответствуют:
import { Plugin } from "gramio";
// Plugin использует тот же API Composer внутри
const myPlugin = new Plugin("my-plugin")
.decorate({ db: myDatabase }) // статическое обогащение
.derive(async () => ({ user: ... })) // обогащение на каждый запрос
.on("message", handler); // обработчик событийcreateComposer() — Создание собственных фреймворков
Если вы строите фреймворк поверх @gramio/composer и вам нужны собственные методы-хелперы (как hears(), command(), reaction() в GramIO), используйте createComposer():
import { createComposer, eventTypes } from "@gramio/composer";
const { Composer } = createComposer({
discriminator: (ctx: BaseCtx) => ctx.updateType,
types: eventTypes<{ message: MessageCtx; callback_query: CallbackCtx }>(),
methods: {
hears(trigger: RegExp | string, handler: (ctx: MessageCtx) => unknown) {
return this.on("message", (ctx, next) => {
const text = ctx.text;
if (typeof trigger === "string" ? text === trigger : trigger.test(text ?? ""))
return handler(ctx);
return next();
});
},
},
});
// Кастомные методы сохраняются через все цепочки:
const app = new Composer()
.hears(/hello/, h1) // кастомный метод
.on("message", h2) // встроенный — TMethods всё ещё сохранён
.hears(/bye/, h3); // кастомный метод по-прежнему доступенtypes + eventTypes(): TypeScript не умеет частично выводить типовые аргументы. Фантомное поле types с хелпером eventTypes() позволяет задать TEventMap без потери вывода TMethods:
// Вместо явных типовых параметров (не выведет TMethods):
createComposer<BaseCtx, { message: MessageCtx }>({ ... })
// Используйте паттерн phantom types:
createComposer({
discriminator: (ctx: BaseCtx) => ctx.updateType,
types: eventTypes<{ message: MessageCtx }>(), // выводится, не задаётся явно
methods: { /* TMethods выводится отсюда */ },
})Рантаймовая проверка конфликтов бросает ошибку, если ключ methods совпадает со встроенным именем метода.
defineComposerMethods() — generic кастомные методы с derive
Когда кастомные методы имеют generic-сигнатуры для захвата накопленных derive, нужен defineComposerMethods(). TypeScript не может вывести generic-сигнатуры методов, когда TMethods вложен внутрь возвращаемого типа createComposer:
import { defineComposerMethods, createComposer } from "@gramio/composer";
import type { ComposerLike, ContextOf, Middleware } from "@gramio/composer";
const methods = defineComposerMethods({
command<TThis extends ComposerLike<TThis>>(
this: TThis,
name: string,
handler: Middleware<MessageCtx & ContextOf<TThis>>,
): TThis {
return this.on("message", (ctx, next) => {
if (ctx.text === `/${name}`) return handler(ctx, next);
return next();
});
},
});
const { Composer } = createComposer<BaseCtx, EventMap, typeof methods>({
discriminator: (ctx) => ctx.updateType,
methods,
});
// Derive автоматически попадает в обработчик — без аннотаций:
new Composer()
.derive(() => ({ user: { id: 1, name: "Alice" } }))
.command("start", (ctx) => {
ctx.user.id; // ✅ типизировано — выводится через ContextOf<TThis>
ctx.text; // ✅ из MessageCtx
});ContextOf<T> — тип текущего контекста
Извлекает TOut (полный накопленный контекст после всех derive()/decorate()) из инстанса Composer или EventComposer:
import type { ContextOf } from "@gramio/composer";
type Ctx = ContextOf<typeof myComposer>;
// Ctx = накопленный контекст со всеми результатами derive()EventContextOf<T, E> — тип контекста для конкретного события
Извлекает контекст для конкретного события из инстанса composer, включая как глобальные, так и per-event derive:
import type { EventContextOf } from "@gramio/composer";
// Per-event derive: виден только в обработчиках 'message'
composer.derive(['message'], () => ({ messageData: "..." }));
type MessageCtx = EventContextOf<typeof composer, 'message'>;
// Включает глобальные derive И messageDataComposerLike<T> — минимальный структурный тип для ограничений this
Минимальный интерфейс { on(event: any, handler: any): T } для F-bounded ограничения TThis в кастомных методах. Делает this.on(...) полностью типизированным и возвращающим TThis без кастов.
Система макросов
Регистрируйте переиспользуемые поведения, которые обработчики активируют декларативно через объект опций. Удобно для сквозных задач — аутентификации, rate limiting, валидации — без засорения тел обработчиков проверками:
// Регистрируем макрос
const app = new Composer().macro("adminOnly", {
preHandler: async (ctx, next) => {
if (ctx.userId !== ADMIN_ID) return ctx.reply("Только для админов");
return next();
},
});
// Активируем в конкретном обработчике через опции:
app.on("message", handler, { adminOnly: true });
app.on("callback_query", handler, { adminOnly: true });macro() принимает:
- Объект
MacroHooks— для булевого сокращения ({ adminOnly: true }) - Функцию
(opts) => MacroHooks— для параметризованных опций ({ throttle: { limit: 3 } })
Поля MacroHooks:
preHandler— middleware, выполняющийся перед обработчикомderive— функция обогащения контекста; возвратvoidостанавливает цепочку