@gramio/composer
@gramio/composer is the general-purpose, type-safe middleware composition library that powers GramIO's internals. If you're writing a plugin, building a framework on top of GramIO, or just want to understand how context enrichment works — this is the place to start.
Installation
npm install @gramio/composeryarn add @gramio/composerpnpm add @gramio/composerbun add @gramio/composerCore Concepts
A Composer is a chainable middleware pipeline. Each method registers a new middleware step and returns the (updated) composer for chaining:
import { Composer } from "@gramio/composer";
const app = new Composer<{ request: Request }>()
.use(logger)
.derive(fetchUser)
.guard(isAuthenticated)
.use(handler);use()
Register raw middleware. The handler receives (context, next) and must call next() to continue the chain:
app.use(async (ctx, next) => {
console.log("before");
await next();
console.log("after");
});derive()
Enriches context with computed values. The returned object is merged into the context for all downstream middleware:
app.derive(async (ctx) => {
const user = await db.findUser(ctx.userId);
return { user };
});
// ctx.user is now available downstreamdecorate()
Like derive(), but for static values that don't need per-request computation. Assigns the object once at registration time and reuses the same reference — zero function call overhead:
app.decorate({ db: myDatabase, config: appConfig });
// ctx.db and ctx.config available on every request, no overheadSupports scoping with { as: "scoped" } or { as: "global" } to propagate through extend().
guard()
Only continues the chain if the predicate returns true:
app.guard((ctx) => ctx.user.isAdmin);
// Subsequent middleware only runs for adminswhen()
Build-time conditional middleware registration. The condition is evaluated once at startup, not per-request. Properties added inside the block are typed as Partial (optional):
const app = new Composer()
.when(process.env.NODE_ENV !== "production", (c) =>
c.use(verboseLogger)
)
.when(config.features.analytics, (c) =>
c.derive(() => ({ analytics: createAnalyticsClient() }))
);Differences from branch():
when()— condition evaluated once at startup (build-time)branch()— condition evaluated on every request (runtime)
Nested when() blocks work. Dedup keys, error handlers, and error definitions propagate from the conditional block.
Observability
inspect()
Returns a read-only snapshot of all registered middleware with metadata:
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" },
// ]When a named plugin is extended, the plugin field shows the source:
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 per-middleware instrumentation hook. Zero overhead when not used — middleware functions are passed through unwrapped when no tracer is set:
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();
};
});The TraceHandler callback:
- Is called before each middleware executes with
MiddlewareInfoand context - May return a cleanup function
(error?: unknown) => void - Cleanup is called after middleware completes (with no args on success, with the error on failure)
- Errors still propagate to
onErrorafter cleanup
Scope System
The scope system controls how middleware propagates when one composer extends another:
| Scope | Behavior |
|---|---|
"local" (default) | Isolated inside an isolation wrapper — context does not leak to parent |
"scoped" | Adds directly to parent as a local entry — visible to parent's downstream middleware |
"global" | Adds to parent as global — continues propagating through further extend() calls |
Promote a whole composer to a scope with .as():
const plugin = new Composer({ name: "auth" })
.derive(function getUser() { return { user: "alice" }; })
.as("scoped"); // everything in this composer is scoped
app.extend(plugin); // getUser is now visible in app's downstreamError Handling
class NotFoundError extends Error {}
const app = new Composer()
.error("NotFound", NotFoundError)
.onError(({ error, kind, context }) => {
if (kind === "NotFound") {
context.send("Resource not found");
return "handled";
}
})
.use(() => { throw new NotFoundError("Item missing"); });Multiple onError() handlers are evaluated in order — the first to return a non-undefined value wins. Errors without a matching handler are logged via console.error.
Plugin Development
For plugin authors, @gramio/composer is the foundation of GramIO's Plugin class. The concepts map directly:
import { Plugin } from "gramio";
// Plugin uses the same Composer API internally
const myPlugin = new Plugin("my-plugin")
.decorate({ db: myDatabase }) // static enrichment
.derive(async () => ({ user: ... })) // per-request enrichment
.on("message", handler); // event handlerFor advanced plugin creation that needs custom shorthand methods or observability, work directly with @gramio/composer.
createComposer() — Building Custom Frameworks
If you're building a framework on top of @gramio/composer and need custom shorthand methods (like GramIO's own hears(), command(), reaction()), use 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();
});
},
command(cmd: string, handler: (ctx: MessageCtx) => unknown) {
return this.on("message", (ctx, next) => {
if (ctx.text?.startsWith(`/${cmd}`)) return handler(ctx);
return next();
});
},
},
});
// Custom methods survive through all chain operations:
const app = new Composer()
.hears(/hello/, h1) // custom method
.on("message", h2) // built-in — TMethods still preserved
.hears(/bye/, h3); // custom method still availabletypes + eventTypes(): TypeScript cannot partially infer type arguments. The types phantom field with eventTypes() helper lets you specify TEventMap without losing TMethods inference:
// Instead of explicit type parameters (can't infer TMethods):
createComposer<BaseCtx, { message: MessageCtx }>({ ... })
// Use the phantom types pattern:
createComposer({
discriminator: (ctx: BaseCtx) => ctx.updateType,
types: eventTypes<{ message: MessageCtx }>(), // inferred, not explicit
methods: { /* TMethods inferred from here */ },
})A runtime conflict check throws if a methods key collides with a built-in method name.