Skip to content

gramiojs/composer

Repository files navigation

@gramio/composer

npm npm downloads JSR JSR Score

General-purpose, type-safe middleware composition library for TypeScript. Zero dependencies. Cross-runtime (Bun / Node.js / Deno).

Features

  • Koa-style onion middleware composition
  • Type-safe context accumulation via derive()
  • Scope isolation (local / scoped / global) like Elysia
  • Plugin deduplication by name + seed
  • Abstract event system via factory pattern (.on())
  • Concurrent event queue with graceful shutdown
  • Branching, routing, forking, lazy middleware

Installation

# npm
npm install @gramio/composer

# bun
bun add @gramio/composer

# deno
deno add jsr:@gramio/composer

Quick Start

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

const app = new Composer<{ request: Request }>()
  .use(async (ctx, next) => {
    console.log("before");
    await next();
    console.log("after");
  })
  .derive((ctx) => ({
    url: new URL(ctx.request.url),
  }))
  .use((ctx, next) => {
    console.log(ctx.url.pathname); // typed!
    return next();
  });

await app.run({ request: new Request("https://example.com/hello") });

API

compose(middlewares)

Standalone Koa-style onion composition. Takes an array of middleware, returns a single composed middleware.

import { compose } from "@gramio/composer";

const handler = compose([
  async (ctx, next) => { console.log(1); await next(); console.log(4); },
  async (ctx, next) => { console.log(2); await next(); console.log(3); },
]);

await handler({});
// 1 → 2 → 3 → 4

Composer

The core class. Registers middleware with type-safe context accumulation.

use(...middleware)

Register raw middleware functions.

app.use((ctx, next) => {
  // do something
  return next();
});

derive(handler, options?)

Compute and assign additional context properties. Subsequent middleware sees the new types.

app
  .derive((ctx) => ({ user: getUser(ctx) }))
  .use((ctx, next) => {
    ctx.user; // typed!
    return next();
  });

With scope propagation:

const plugin = new Composer({ name: "auth" })
  .derive((ctx) => ({ user: getUser(ctx) }), { as: "scoped" });

guard(predicate, ...middleware)

Two modes depending on whether handlers are provided:

With handlers — run middleware as side-effects when true, always continue the chain:

app.guard(
  (ctx): ctx is WithText => "text" in ctx,
  (ctx, next) => { /* ctx.text is typed */ return next(); }
);

Without handlers (gate mode) — if false, stop this composer's remaining middleware. When a type predicate is used, downstream context is narrowed:

// Only admin can reach subsequent middleware
app
  .guard((ctx) => ctx.role === "admin")
  .use(adminOnlyHandler);  // skipped if not admin

// Type predicate narrows context for all downstream handlers
app
  .guard((ctx): ctx is Ctx & { text: string } => "text" in ctx)
  .on("message", (ctx, next) => {
    ctx.text; // string (narrowed by guard)
    return next();
  });

When used inside an extend()-ed plugin, the guard stops the plugin's chain but the parent continues:

const adminPlugin = new Composer()
  .guard((ctx) => ctx.isAdmin)
  .use(adminDashboard);          // skipped if not admin

app
  .extend(adminPlugin)  // guard inside, isolated
  .use(alwaysRuns);     // parent continues regardless

branch(predicate, onTrue, onFalse?)

If/else branching. Static boolean optimization at registration time.

app.branch(
  (ctx) => ctx.isAdmin,
  adminHandler,
  userHandler
);

route(router, cases, fallback?)

Multi-way dispatch (like a switch).

app.route(
  (ctx) => ctx.type,
  {
    message: handleMessage,
    callback: handleCallback,
  },
  handleFallback
);

fork(...middleware)

Fire-and-forget parallel execution. Doesn't block the main chain.

app.fork(analyticsMiddleware);

tap(...middleware)

Run middleware but always continue the chain (cannot stop it).

app.tap(loggingMiddleware);

lazy(factory)

Dynamic middleware selection. Factory is called on every invocation (not cached).

app.lazy((ctx) => ctx.premium ? premiumHandler : freeHandler);

onError(handler)

Error boundary for all subsequent middleware.

app.onError((ctx, error) => {
  console.error(error);
});

group(fn)

Isolated sub-chain. Derives inside the group don't leak to the parent.

app.group((g) => {
  g.derive(() => ({ internal: true }))
   .use((ctx, next) => {
     ctx.internal; // available here
     return next();
   });
});
// ctx.internal is NOT available here

extend(other)

Merge another composer. Scope-aware and dedup-aware.

const auth = new Composer({ name: "auth" })
  .derive(() => ({ user: "alice" }))
  .as("scoped");

app.extend(auth);
// app now sees ctx.user

as(scope)

Promote all middleware to "scoped" (one level) or "global" (all levels).

compose() / run(context)

Compile to a single middleware or run directly.

const handler = app.compose();
await handler(ctx);

// or
await app.run(ctx);

Scope System

When parent.extend(child):

Child scope Effect in parent
local (default) Isolated via Object.create() — derives don't leak
scoped Merged into parent, stops there (one level)
global Merged into parent and propagates to all ancestors

Plugin Deduplication

Composers with a name are deduplicated. Same name + seed = skipped on second extend.

const auth = new Composer({ name: "auth" });
app.extend(auth); // applied
app.extend(auth); // skipped

Different seed = different plugin:

const limit100 = new Composer({ name: "rate-limit", seed: { max: 100 } });
const limit200 = new Composer({ name: "rate-limit", seed: { max: 200 } });
app.extend(limit100); // applied
app.extend(limit200); // applied (different seed)

createComposer(config) — Event System

Factory that creates a Composer class with .on() event discrimination.

import { createComposer } from "@gramio/composer";

interface BaseCtx { updateType: string }
interface MessageCtx extends BaseCtx { text?: string }
interface CallbackCtx extends BaseCtx { data?: string }

const { Composer, EventQueue } = createComposer<BaseCtx, {
  message: MessageCtx;
  callback_query: CallbackCtx;
}>({
  discriminator: (ctx) => ctx.updateType,
});

const app = new Composer()
  .derive(() => ({ timestamp: Date.now() }))
  .on("message", (ctx, next) => {
    ctx.text;      // string | undefined
    ctx.timestamp;  // number
    return next();
  })
  .on("callback_query", (ctx, next) => {
    ctx.data;       // string | undefined
    return next();
  });

.on() with filters

Filter-only (no event name) — the 2-arg on(filter, handler) applies the filter to all events without discriminating by event type:

// Type-narrowing filter — handler sees narrowed context across all compatible events
app.on(
  (ctx): ctx is { text: string } => typeof (ctx as any).text === "string",
  (ctx, next) => {
    ctx.text; // string (narrowed)
    return next();
  },
);

// Boolean filter — no narrowing, handler gets base TOut
app.on(
  (ctx) => ctx.updateType === "message",
  (ctx, next) => {
    // no type narrowing, full context
    return next();
  },
);

Event + filter — the 3-arg on(event, filter, handler) supports both type-narrowing predicates and boolean filters:

// Type-narrowing filter — handler sees narrowed context
app.on(
  "message",
  (ctx): ctx is MessageCtx & { text: string } => ctx.text !== undefined,
  (ctx, next) => {
    ctx.text; // string (narrowed, not string | undefined)
    return next();
  },
);

// Boolean filter — no narrowing, handler sees full context
app.on(
  "message",
  (ctx) => ctx.text !== undefined,
  (ctx, next) => {
    ctx.text; // string | undefined (not narrowed)
    return next();
  },
);

The 2-arg on() also accepts an optional Patch generic for context extensions (useful in custom methods):

app.on<"message", { args: string }>("message", (ctx, next) => {
  ctx.args; // string — type-safe without casting
  return next();
});

.use() supports the same Patch generic — handy when a custom method enriches context before delegating to a user-provided handler:

app.use<{ args: string }>((ctx, next) => {
  ctx.args; // string — type-safe without casting
  return next();
});

Patch does not change TOut — it is a local escape hatch for one handler, not a permanent context extension. Use derive() when you want the addition to propagate to all downstream middleware.

types + eventTypes() — phantom type inference

TypeScript cannot partially infer type arguments, so when you need both TEventMap and TMethods inferred together, use the types phantom field with the eventTypes() helper instead of explicit type parameters:

import { createComposer, eventTypes } from "@gramio/composer";

// eventTypes<T>() returns undefined at runtime — purely for inference
const { Composer } = createComposer({
  discriminator: (ctx: BaseCtx) => ctx.updateType,
  types: eventTypes<{ message: MessageCtx; callback_query: CallbackCtx }>(),
});
// TBase inferred from discriminator, TEventMap inferred from types

methods — custom prototype methods

Inject framework-specific DX sugar directly onto the Composer prototype via the methods config option. Method bodies receive this typed as the full EventComposer, giving access to .on(), .use(), .derive(), etc.

const { Composer } = createComposer({
  discriminator: (ctx: BaseCtx) => ctx.updateType,
  types: eventTypes<{ message: MessageCtx }>(),
  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 instanceof RegExp && text && trigger.test(text))
        ) {
          return handler(ctx);
        }
        return next();
      });
    },
  },
});

const bot = new Composer();
bot.hears(/hello/, handler);            // custom method
bot.on("message", h).hears(/hi/, h2);  // chaining works — TMethods preserved

Custom methods are preserved through all method chains (on, use, derive, extend, etc.). A runtime conflict check throws if a method name collides with a built-in (e.g. on, use, derive).

EventQueue

Concurrent event queue with graceful shutdown.

import { EventQueue } from "@gramio/composer";

const queue = new EventQueue<RawEvent>(async (event) => {
  const ctx = createContext(event);
  return app.run(ctx);
});

queue.add(event);
queue.addBatch(events);

// Graceful shutdown (waits up to 5s for pending handlers)
await queue.stop(5000);

Utilities

import { noopNext, skip, stop } from "@gramio/composer";

noopNext;  // () => Promise.resolve()
skip;      // middleware that calls next()
stop;      // middleware that does NOT call next()

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published