@gramio/composer 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,13 +119,21 @@ app.guard(
119
119
  );
120
120
  ```
121
121
 
122
- **Without handlers** gate the chain: if false, stop this composer's remaining middleware:
122
+ **Without handlers (gate mode)** if false, stop this composer's remaining middleware. When a type predicate is used, downstream context is narrowed:
123
123
 
124
124
  ```ts
125
125
  // Only admin can reach subsequent middleware
126
126
  app
127
127
  .guard((ctx) => ctx.role === "admin")
128
128
  .use(adminOnlyHandler); // skipped if not admin
129
+
130
+ // Type predicate narrows context for all downstream handlers
131
+ app
132
+ .guard((ctx): ctx is Ctx & { text: string } => "text" in ctx)
133
+ .on("message", (ctx, next) => {
134
+ ctx.text; // string (narrowed by guard)
135
+ return next();
136
+ });
129
137
  ```
130
138
 
131
139
  When used inside an `extend()`-ed plugin, the guard stops the plugin's chain but the parent continues:
@@ -305,6 +313,186 @@ const app = new Composer()
305
313
  });
306
314
  ```
307
315
 
316
+ #### `.on()` with filters
317
+
318
+ **Filter-only (no event name)** — the 2-arg `on(filter, handler)` applies the filter to **all** events without discriminating by event type:
319
+
320
+ ```ts
321
+ // Type-narrowing filter — handler sees narrowed context across all compatible events
322
+ app.on(
323
+ (ctx): ctx is { text: string } => typeof (ctx as any).text === "string",
324
+ (ctx, next) => {
325
+ ctx.text; // string (narrowed)
326
+ return next();
327
+ },
328
+ );
329
+
330
+ // Boolean filter — no narrowing, handler gets base TOut
331
+ app.on(
332
+ (ctx) => ctx.updateType === "message",
333
+ (ctx, next) => {
334
+ // no type narrowing, full context
335
+ return next();
336
+ },
337
+ );
338
+ ```
339
+
340
+ **Event + filter** — the 3-arg `on(event, filter, handler)` supports both type-narrowing predicates and boolean filters:
341
+
342
+ ```ts
343
+ // Type-narrowing filter — handler sees narrowed context
344
+ app.on(
345
+ "message",
346
+ (ctx): ctx is MessageCtx & { text: string } => ctx.text !== undefined,
347
+ (ctx, next) => {
348
+ ctx.text; // string (narrowed, not string | undefined)
349
+ return next();
350
+ },
351
+ );
352
+
353
+ // Boolean filter — no narrowing, handler sees full context
354
+ app.on(
355
+ "message",
356
+ (ctx) => ctx.text !== undefined,
357
+ (ctx, next) => {
358
+ ctx.text; // string | undefined (not narrowed)
359
+ return next();
360
+ },
361
+ );
362
+ ```
363
+
364
+ The 2-arg `on()` also accepts an optional `Patch` generic for context extensions (useful in custom methods):
365
+
366
+ ```ts
367
+ app.on<"message", { args: string }>("message", (ctx, next) => {
368
+ ctx.args; // string — type-safe without casting
369
+ return next();
370
+ });
371
+ ```
372
+
373
+ `.use()` supports the same `Patch` generic — handy when a custom method enriches context before delegating to a user-provided handler:
374
+
375
+ ```ts
376
+ app.use<{ args: string }>((ctx, next) => {
377
+ ctx.args; // string — type-safe without casting
378
+ return next();
379
+ });
380
+ ```
381
+
382
+ `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.
383
+
384
+ #### `types` + `eventTypes()` — phantom type inference
385
+
386
+ 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:
387
+
388
+ ```ts
389
+ import { createComposer, eventTypes } from "@gramio/composer";
390
+
391
+ // eventTypes<T>() returns undefined at runtime — purely for inference
392
+ const { Composer } = createComposer({
393
+ discriminator: (ctx: BaseCtx) => ctx.updateType,
394
+ types: eventTypes<{ message: MessageCtx; callback_query: CallbackCtx }>(),
395
+ });
396
+ // TBase inferred from discriminator, TEventMap inferred from types
397
+ ```
398
+
399
+ #### `methods` — custom prototype methods
400
+
401
+ Inject framework-specific DX sugar directly onto the Composer prototype. 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.
402
+
403
+ **Simple methods** (no access to accumulated derives) work directly in `methods`:
404
+
405
+ ```ts
406
+ const { Composer } = createComposer({
407
+ discriminator: (ctx: BaseCtx) => ctx.updateType,
408
+ types: eventTypes<{ message: MessageCtx }>(),
409
+ methods: {
410
+ hears(trigger: RegExp | string, handler: (ctx: MessageCtx) => unknown) {
411
+ return this.on("message", (ctx, next) => {
412
+ const text = ctx.text;
413
+ if (
414
+ (typeof trigger === "string" && text === trigger) ||
415
+ (trigger instanceof RegExp && text && trigger.test(text))
416
+ ) {
417
+ return handler(ctx);
418
+ }
419
+ return next();
420
+ });
421
+ },
422
+ },
423
+ });
424
+ ```
425
+
426
+ **Methods that receive accumulated derives** require two steps. TypeScript cannot infer generic method signatures when `TMethods` is nested inside the return type of `createComposer`, so use `defineComposerMethods` first — its return type is directly `TMethods`, which preserves generic signatures. Then pass `typeof methods` as the 3rd type argument.
427
+
428
+ Use `ComposerLike<TThis>` as an F-bounded constraint so that `this.on(...)` is fully typed and returns `TThis` — no casts needed.
429
+
430
+ **Pattern: `this: TThis` + `ContextOf<TThis>` — zero annotation at the call site:**
431
+
432
+ ```ts
433
+ import { createComposer, defineComposerMethods, eventTypes } from "@gramio/composer";
434
+ import type { ComposerLike, ContextOf, Middleware } from "@gramio/composer";
435
+
436
+ const methods = defineComposerMethods({
437
+ command<TThis extends ComposerLike<TThis>>(
438
+ this: TThis,
439
+ name: string,
440
+ handler: Middleware<MessageCtx & ContextOf<TThis>>,
441
+ ): TThis {
442
+ const inner: Middleware<MessageCtx & ContextOf<TThis>> = (ctx, next) => {
443
+ if (ctx.text === `/${name}`) return handler(ctx, next);
444
+ return next();
445
+ };
446
+ return this.on("message", inner);
447
+ },
448
+ });
449
+
450
+ const { Composer } = createComposer<BaseCtx, { message: MessageCtx }, typeof methods>({
451
+ discriminator: (ctx) => ctx.updateType,
452
+ methods,
453
+ });
454
+
455
+ // Derives flow into the handler automatically — no annotation needed:
456
+ new Composer()
457
+ .derive(() => ({ user: { id: 1, name: "Alice" } }))
458
+ .command("start", (ctx, next) => {
459
+ ctx.user.id; // ✅ typed — inferred from ContextOf<TThis>
460
+ ctx.text; // ✅ string | undefined — from MessageCtx
461
+ return next();
462
+ });
463
+ ```
464
+
465
+ #### `ContextOf<T>` — extract the current context type
466
+
467
+ Extracts `TOut` from a Composer or EventComposer instance type. Used as `ContextOf<TThis>` in custom method signatures to automatically capture all accumulated derives at the call site.
468
+
469
+ ```ts
470
+ import type { ContextOf } from "@gramio/composer";
471
+
472
+ // From a plain Composer:
473
+ type Ctx = ContextOf<Composer<{ a: number }, { a: number; b: string }>>;
474
+ // Ctx = { a: number; b: string }
475
+
476
+ // In a custom method — TThis is inferred from the caller instance:
477
+ command<TThis extends ComposerLike<TThis>>(
478
+ this: TThis,
479
+ handler: Middleware<ContextOf<TThis>>,
480
+ ): TThis
481
+ ```
482
+
483
+ #### `ComposerLike<T>` — minimal structural type for `this` constraints
484
+
485
+ A minimal interface `{ on(event: any, handler: any): T }` used as an F-bounded constraint on `TThis`. Makes `this.on(...)` fully typed and return `TThis` without casts.
486
+
487
+ ```ts
488
+ import type { ComposerLike } from "@gramio/composer";
489
+
490
+ // Constraint in a custom method:
491
+ command<TThis extends ComposerLike<TThis>>(this: TThis, ...): TThis {
492
+ return this.on("message", inner); // returns TThis — no `as TThis` needed
493
+ }
494
+ ```
495
+
308
496
  ### `EventQueue`
309
497
 
310
498
  Concurrent event queue with graceful shutdown.
@@ -324,6 +512,88 @@ queue.addBatch(events);
324
512
  await queue.stop(5000);
325
513
  ```
326
514
 
515
+ ### Macro System
516
+
517
+ Declarative handler options inspired by [Elysia macros](https://elysiajs.com/patterns/macro.md). Register reusable behaviors (guards, rate-limits, auth) as macros, then activate them via an options object on handler methods.
518
+
519
+ #### `macro(name, definition)` / `macro(definitions)`
520
+
521
+ Register macros on a Composer or EventComposer instance.
522
+
523
+ ```ts
524
+ import { Composer, type MacroDef, type ContextCallback } from "@gramio/composer";
525
+
526
+ // Boolean shorthand macro — plain hooks object
527
+ const onlyAdmin: MacroDef<void, {}> = {
528
+ preHandler: (ctx, next) => {
529
+ if (ctx.role !== "admin") return; // stops chain
530
+ return next();
531
+ },
532
+ };
533
+
534
+ // Parameterized macro — function receiving options
535
+ interface ThrottleOptions {
536
+ limit: number;
537
+ window?: number;
538
+ onLimit?: ContextCallback; // ← replaced with actual ctx type at call site
539
+ }
540
+
541
+ const throttle: MacroDef<ThrottleOptions, {}> = (opts) => ({
542
+ preHandler: createThrottleMiddleware(opts),
543
+ });
544
+
545
+ // Macro with derive — enriches handler context
546
+ interface AuthDerived { user: { id: number; name: string } }
547
+
548
+ const auth: MacroDef<void, AuthDerived> = {
549
+ derive: async (ctx) => {
550
+ const user = await getUser(ctx.token);
551
+ if (!user) return; // void = stop chain (guard behavior)
552
+ return { user };
553
+ },
554
+ };
555
+
556
+ const app = new Composer()
557
+ .macro("onlyAdmin", onlyAdmin)
558
+ .macro({ throttle, auth }); // batch registration
559
+ ```
560
+
561
+ #### `buildFromOptions(macros, options, handler)`
562
+
563
+ Runtime helper that composes a handler with macro hooks. Used internally by frameworks to wire macros into handler methods.
564
+
565
+ ```ts
566
+ import { buildFromOptions } from "@gramio/composer";
567
+
568
+ // Execution order:
569
+ // 1. options.preHandler[] (explicit guards — user controls order)
570
+ // 2. Per-macro in options property order:
571
+ // a. macro.preHandler (guard middleware)
572
+ // b. macro.derive (context enrichment; void = stop chain)
573
+ // 3. Main handler
574
+ const composed = buildFromOptions(
575
+ app["~"].macros,
576
+ { auth: true, throttle: { limit: 5 } },
577
+ mainHandler,
578
+ );
579
+ ```
580
+
581
+ #### Macro Types
582
+
583
+ ```ts
584
+ import type {
585
+ MacroDef, // Macro definition (function or hooks object)
586
+ MacroHooks, // { preHandler?, derive? }
587
+ MacroDefinitions, // Record<string, MacroDef<any, any>>
588
+ ContextCallback, // Marker type for context-aware callbacks
589
+ WithCtx, // Recursively replaces ContextCallback with real ctx type
590
+ HandlerOptions, // Builds the options parameter type for handler methods
591
+ DeriveFromOptions, // Collects derive types from activated macros
592
+ MacroOptionType, // Extracts option type from MacroDef
593
+ MacroDeriveType, // Extracts derive return type from MacroDef
594
+ } from "@gramio/composer";
595
+ ```
596
+
327
597
  ### Utilities
328
598
 
329
599
  ```ts
package/dist/index.cjs CHANGED
@@ -79,7 +79,10 @@ class Composer {
79
79
  name: void 0,
80
80
  seed: void 0,
81
81
  errorsDefinitions: {},
82
- tracer: void 0
82
+ tracer: void 0,
83
+ macros: {},
84
+ /** Phantom type accessor — never set at runtime, used by `ContextOf<T>` */
85
+ Out: void 0
83
86
  };
84
87
  constructor(options) {
85
88
  this["~"].name = options?.name;
@@ -88,6 +91,14 @@ class Composer {
88
91
  invalidate() {
89
92
  this["~"].compiled = null;
90
93
  }
94
+ macro(nameOrDefs, definition) {
95
+ if (typeof nameOrDefs === "string") {
96
+ this["~"].macros[nameOrDefs] = definition;
97
+ } else {
98
+ Object.assign(this["~"].macros, nameOrDefs);
99
+ }
100
+ return this;
101
+ }
91
102
  decorate(values, options) {
92
103
  const mw = (ctx, next) => {
93
104
  Object.assign(ctx, values);
@@ -99,6 +110,7 @@ class Composer {
99
110
  this.invalidate();
100
111
  return this;
101
112
  }
113
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation signature
102
114
  use(...middleware) {
103
115
  for (const fn of middleware) {
104
116
  this["~"].middlewares.push({ fn, scope: "local", type: "use", name: fn.name || void 0 });
@@ -316,6 +328,7 @@ class Composer {
316
328
  this["~"].extended.add(key);
317
329
  }
318
330
  Object.assign(this["~"].errorsDefinitions, other["~"].errorsDefinitions);
331
+ Object.assign(this["~"].macros, other["~"].macros);
319
332
  this["~"].onErrors.push(...other["~"].onErrors);
320
333
  const pluginName = other["~"].name;
321
334
  const isNew = (m) => {
@@ -475,16 +488,36 @@ class EventQueue {
475
488
  }
476
489
  }
477
490
 
491
+ function defineComposerMethods(methods) {
492
+ return methods;
493
+ }
494
+ function eventTypes() {
495
+ return void 0;
496
+ }
478
497
  function createComposer(config) {
479
498
  class EventComposerImpl extends Composer {
480
- on(event, handler) {
481
- const events = Array.isArray(event) ? event : [event];
499
+ on(eventOrFilter, filterOrHandler, handler) {
500
+ if (typeof eventOrFilter === "function") {
501
+ const filter2 = eventOrFilter;
502
+ const actualHandler2 = filterOrHandler;
503
+ const filterLabel = filter2.name || "filter";
504
+ const mw2 = (ctx, next) => {
505
+ if (filter2(ctx)) return actualHandler2(ctx, next);
506
+ return next();
507
+ };
508
+ nameMiddleware(mw2, "on", filterLabel);
509
+ this["~"].middlewares.push({ fn: mw2, scope: "local", type: "on", name: filterLabel });
510
+ this.invalidate();
511
+ return this;
512
+ }
513
+ const events = Array.isArray(eventOrFilter) ? eventOrFilter : [eventOrFilter];
482
514
  const eventLabel = events.join("|");
515
+ const actualHandler = handler ?? filterOrHandler;
516
+ const filter = handler ? filterOrHandler : void 0;
483
517
  const mw = (ctx, next) => {
484
- if (events.includes(config.discriminator(ctx))) {
485
- return handler(ctx, next);
486
- }
487
- return next();
518
+ if (!events.includes(config.discriminator(ctx))) return next();
519
+ if (filter && !filter(ctx)) return next();
520
+ return actualHandler(ctx, next);
488
521
  };
489
522
  nameMiddleware(mw, "on", eventLabel);
490
523
  this["~"].middlewares.push({ fn: mw, scope: "local", type: "on", name: eventLabel });
@@ -512,6 +545,19 @@ function createComposer(config) {
512
545
  return this;
513
546
  }
514
547
  }
548
+ if (config.methods) {
549
+ for (const [name, fn] of Object.entries(config.methods)) {
550
+ if (name in EventComposerImpl.prototype) {
551
+ throw new Error(`Custom method "${name}" conflicts with built-in method`);
552
+ }
553
+ Object.defineProperty(EventComposerImpl.prototype, name, {
554
+ value: fn,
555
+ writable: true,
556
+ configurable: true,
557
+ enumerable: false
558
+ });
559
+ }
560
+ }
515
561
  return {
516
562
  Composer: EventComposerImpl,
517
563
  compose,
@@ -519,10 +565,53 @@ function createComposer(config) {
519
565
  };
520
566
  }
521
567
 
568
+ function buildFromOptions(macros, options, handler) {
569
+ if (!options) return handler;
570
+ const chain = [];
571
+ const preHandler = options.preHandler;
572
+ if (preHandler) {
573
+ if (Array.isArray(preHandler)) {
574
+ chain.push(...preHandler);
575
+ } else {
576
+ chain.push(preHandler);
577
+ }
578
+ }
579
+ for (const key of Object.keys(options)) {
580
+ if (key === "preHandler") continue;
581
+ const value = options[key];
582
+ if (value === false || value == null) continue;
583
+ const def = macros[key];
584
+ if (!def) continue;
585
+ const hooks = typeof def === "function" ? def(value === true ? void 0 : value) : def;
586
+ if (hooks.preHandler) {
587
+ if (Array.isArray(hooks.preHandler)) {
588
+ chain.push(...hooks.preHandler);
589
+ } else {
590
+ chain.push(hooks.preHandler);
591
+ }
592
+ }
593
+ if (hooks.derive) {
594
+ const deriveFn = hooks.derive;
595
+ chain.push(async (ctx, next) => {
596
+ const derived = await deriveFn(ctx);
597
+ if (derived == null) return;
598
+ Object.assign(ctx, derived);
599
+ return next();
600
+ });
601
+ }
602
+ }
603
+ chain.push(handler);
604
+ if (chain.length === 1) return chain[0];
605
+ return compose(chain);
606
+ }
607
+
522
608
  exports.Composer = Composer;
523
609
  exports.EventQueue = EventQueue;
610
+ exports.buildFromOptions = buildFromOptions;
524
611
  exports.compose = compose;
525
612
  exports.createComposer = createComposer;
613
+ exports.defineComposerMethods = defineComposerMethods;
614
+ exports.eventTypes = eventTypes;
526
615
  exports.noopNext = noopNext;
527
616
  exports.skip = skip;
528
617
  exports.stop = stop;