Skip to content

@gramio/composer ​

npmJSR

@gramio/composer — универсальная типобезопасная библиотека композиции middleware, на которой строятся внутренности GramIO. Если вы пишете плагин, строите фреймворк поверх GramIO или просто хотите понять, как работает обогащение контекста — добро пожаловать.

Установка ​

sh
npm install @gramio/composer
sh
yarn add @gramio/composer
sh
pnpm add @gramio/composer
sh
bun add @gramio/composer

Основные концепции ​

Composer — цепочечный пайплайн middleware. Каждый метод регистрирует новый шаг и возвращает обновлённый композер для цепочки:

ts
import { Composer } from "@gramio/composer";

const app = new Composer<{ request: Request }>()
    .use(logger)
    .derive(fetchUser)
    .guard(isAuthenticated)
    .use(handler);

use() ​

Регистрирует middleware напрямую. Обработчик получает (context, next) и должен вызвать next() для продолжения цепочки:

ts
app.use(async (ctx, next) => {
    console.log("до");
    await next();
    console.log("после");
});

derive() ​

Обогащает контекст вычисленными значениями. Возвращённый объект мержится в контекст для всех последующих middleware:

ts
app.derive(async (ctx) => {
    const user = await db.findUser(ctx.userId);
    return { user };
});
// ctx.user теперь доступен ниже по цепочке

decorate() ​

Как derive(), но для статических значений без вычислений на каждый запрос. Присваивает объект один раз при регистрации и переиспользует ту же ссылку — нулевые накладные расходы:

ts
app.decorate({ db: myDatabase, config: appConfig });
// ctx.db и ctx.config доступны на каждый запрос без оверхеда

Поддерживает { as: "scoped" } или { as: "global" } для распространения через extend().

guard() ​

Продолжает цепочку только если предикат возвращает true:

ts
app.guard((ctx) => ctx.user.isAdmin);
// Последующие middleware работают только для администраторов

when() ​

Условная регистрация middleware на этапе запуска. Условие вычисляется один раз при старте приложения, не на каждый запрос. Свойства из блока типизируются как Partial (опциональные):

ts
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 с метаданными:

ts
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 показывает источник:

ts
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. Нулевые накладные расходы без трассировки:

ts
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:

  1. Вызывается перед каждым middleware с MiddlewareInfo и контекстом
  2. Может возвращать функцию очистки (error?: unknown) => void
  3. Очистка вызывается после завершения middleware (без аргументов при успехе, с ошибкой при сбое)
  4. Ошибки продолжают проброс в onError после очистки

registeredEvents() ​

Возвращает Set<string> со всеми именами событий, на которые у композера есть хэндлеры — собирает из .on() и event-specific .derive(). Композитные события ("message|callback_query") разворачиваются, entity-паттерны ("message:text") сохраняются как есть.

ts
const app = new Composer()
    .on("message", h1)
    .on(["callback_query", "inline_query"], h2)
    .derive("chat_member", h3);

app.registeredEvents();
// → Set { "message", "callback_query", "inline_query", "chat_member" }

Под капотом этого работает автогенерация allowed_updates в gramio 0.9 — bot.start() обходит все композеры через registeredEvents() и смотрит, какие апдейты реально используются.

Система скоупов ​

Скоупы контролируют как middleware распространяется при расширении одного композера другим:

СкоупПоведение
"local" (по умолчанию)Изолирован в обёртке — контекст не утекает в родителя
"scoped"Добавляется в родителя как локальная запись — видно последующим middleware родителя
"global"Добавляется в родителя как глобальный — продолжает распространяться через дальнейшие extend()

Переведите весь композер в нужный скоуп через .as():

ts
const plugin = new Composer({ name: "auth" })
    .derive(function getUser() { return { user: "alice" }; })
    .as("scoped"); // весь плагин становится scoped

app.extend(plugin); // getUser виден в downstream-middleware app

Обработка ошибок ​

ts
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. Концепции напрямую соответствуют:

ts
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():

ts
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:

ts
// Вместо явных типовых параметров (не выведет 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:

ts
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:

ts
import type { ContextOf } from "@gramio/composer";

type Ctx = ContextOf<typeof myComposer>;
// Ctx = накопленный контекст со всеми результатами derive()

EventContextOf<T, E> — тип контекста для конкретного события ​

Извлекает контекст для конкретного события из инстанса composer, включая как глобальные, так и per-event derive:

ts
import type { EventContextOf } from "@gramio/composer";

// Per-event derive: виден только в обработчиках 'message'
composer.derive(['message'], () => ({ messageData: "..." }));

type MessageCtx = EventContextOf<typeof composer, 'message'>;
// Включает глобальные derive И messageData

ComposerLike<T> — минимальный структурный тип для ограничений this ​

Минимальный интерфейс { on(event: any, handler: any): T } для F-bounded ограничения TThis в кастомных методах. Делает this.on(...) полностью типизированным и возвращающим TThis без кастов.

Система макросов ​

Регистрируйте переиспользуемые поведения, которые обработчики активируют декларативно через объект опций. Удобно для сквозных задач — аутентификации, rate limiting, валидации — без засорения тел обработчиков проверками:

ts
// Регистрируем макрос
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 останавливает цепочку