@gramio/composer 0.2.0 → 0.3.1

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:
@@ -274,6 +282,32 @@ app.extend(limit100); // applied
274
282
  app.extend(limit200); // applied (different seed)
275
283
  ```
276
284
 
285
+ > [!WARNING]
286
+ > **Dedup removes middleware at registration time — not at runtime per-request.**
287
+ >
288
+ > If a shared plugin (e.g. `withUser`) is extended only inside sub-composers, its
289
+ > `derive` runs inside each sub-composer's isolation group. When dedup removes the
290
+ > derive from the second sub-composer, `ctx.user` set in the first group is **not
291
+ > visible** in the second — TypeScript types are correct, runtime value is `undefined`.
292
+ >
293
+ > **Fix:** extend the shared composer at the level where its data must be available,
294
+ > and let sub-composers extend it only for type safety (dedup prevents double execution).
295
+ >
296
+ > ```ts
297
+ > // ✅ correct — withUser runs once on the real ctx, both routers see ctx.user
298
+ > app
299
+ > .extend(withUser) // derive on real ctx
300
+ > .extend(adminRouter) // withUser inside → deduped (skipped)
301
+ > .extend(chatRouter); // withUser inside → deduped (skipped)
302
+ >
303
+ > // ⚠️ risky — works only if routers are mutually exclusive (one handles per update)
304
+ > app
305
+ > .extend(adminRouter) // withUser runs in isolation group
306
+ > .extend(chatRouter); // withUser deduped → chatHandlers can't see ctx.user
307
+ > ```
308
+ >
309
+ > See [`docs/layered-composers.md`](./docs/layered-composers.md) for the full breakdown.
310
+
277
311
  ### `createComposer(config)` — Event System
278
312
 
279
313
  Factory that creates a Composer class with `.on()` event discrimination.
@@ -305,6 +339,74 @@ const app = new Composer()
305
339
  });
306
340
  ```
307
341
 
342
+ #### `.on()` with filters
343
+
344
+ **Filter-only (no event name)** — the 2-arg `on(filter, handler)` applies the filter to **all** events without discriminating by event type:
345
+
346
+ ```ts
347
+ // Type-narrowing filter — handler sees narrowed context across all compatible events
348
+ app.on(
349
+ (ctx): ctx is { text: string } => typeof (ctx as any).text === "string",
350
+ (ctx, next) => {
351
+ ctx.text; // string (narrowed)
352
+ return next();
353
+ },
354
+ );
355
+
356
+ // Boolean filter — no narrowing, handler gets base TOut
357
+ app.on(
358
+ (ctx) => ctx.updateType === "message",
359
+ (ctx, next) => {
360
+ // no type narrowing, full context
361
+ return next();
362
+ },
363
+ );
364
+ ```
365
+
366
+ **Event + filter** — the 3-arg `on(event, filter, handler)` supports both type-narrowing predicates and boolean filters:
367
+
368
+ ```ts
369
+ // Type-narrowing filter — handler sees narrowed context
370
+ app.on(
371
+ "message",
372
+ (ctx): ctx is MessageCtx & { text: string } => ctx.text !== undefined,
373
+ (ctx, next) => {
374
+ ctx.text; // string (narrowed, not string | undefined)
375
+ return next();
376
+ },
377
+ );
378
+
379
+ // Boolean filter — no narrowing, handler sees full context
380
+ app.on(
381
+ "message",
382
+ (ctx) => ctx.text !== undefined,
383
+ (ctx, next) => {
384
+ ctx.text; // string | undefined (not narrowed)
385
+ return next();
386
+ },
387
+ );
388
+ ```
389
+
390
+ The 2-arg `on()` also accepts an optional `Patch` generic for context extensions (useful in custom methods):
391
+
392
+ ```ts
393
+ app.on<"message", { args: string }>("message", (ctx, next) => {
394
+ ctx.args; // string — type-safe without casting
395
+ return next();
396
+ });
397
+ ```
398
+
399
+ `.use()` supports the same `Patch` generic — handy when a custom method enriches context before delegating to a user-provided handler:
400
+
401
+ ```ts
402
+ app.use<{ args: string }>((ctx, next) => {
403
+ ctx.args; // string — type-safe without casting
404
+ return next();
405
+ });
406
+ ```
407
+
408
+ `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.
409
+
308
410
  #### `types` + `eventTypes()` — phantom type inference
309
411
 
310
412
  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:
@@ -322,7 +424,9 @@ const { Composer } = createComposer({
322
424
 
323
425
  #### `methods` — custom prototype methods
324
426
 
325
- 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.
427
+ 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.
428
+
429
+ **Simple methods** (no access to accumulated derives) work directly in `methods`:
326
430
 
327
431
  ```ts
328
432
  const { Composer } = createComposer({
@@ -343,13 +447,92 @@ const { Composer } = createComposer({
343
447
  },
344
448
  },
345
449
  });
450
+ ```
451
+
452
+ **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.
453
+
454
+ Use `ComposerLike<TThis>` as an F-bounded constraint so that `this.on(...)` is fully typed and returns `TThis` — no casts needed.
455
+
456
+ **Pattern: `this: TThis` + `ContextOf<TThis>` — zero annotation at the call site:**
457
+
458
+ ```ts
459
+ import { createComposer, defineComposerMethods, eventTypes } from "@gramio/composer";
460
+ import type { ComposerLike, ContextOf, Middleware } from "@gramio/composer";
461
+
462
+ const methods = defineComposerMethods({
463
+ command<TThis extends ComposerLike<TThis>>(
464
+ this: TThis,
465
+ name: string,
466
+ handler: Middleware<MessageCtx & ContextOf<TThis>>,
467
+ ): TThis {
468
+ const inner: Middleware<MessageCtx & ContextOf<TThis>> = (ctx, next) => {
469
+ if (ctx.text === `/${name}`) return handler(ctx, next);
470
+ return next();
471
+ };
472
+ return this.on("message", inner);
473
+ },
474
+ });
346
475
 
347
- const bot = new Composer();
348
- bot.hears(/hello/, handler); // custom method
349
- bot.on("message", h).hears(/hi/, h2); // chaining works — TMethods preserved
476
+ const { Composer } = createComposer<BaseCtx, { message: MessageCtx }, typeof methods>({
477
+ discriminator: (ctx) => ctx.updateType,
478
+ methods,
479
+ });
480
+
481
+ // Derives flow into the handler automatically — no annotation needed:
482
+ new Composer()
483
+ .derive(() => ({ user: { id: 1, name: "Alice" } }))
484
+ .command("start", (ctx, next) => {
485
+ ctx.user.id; // ✅ typed — inferred from ContextOf<TThis>
486
+ ctx.text; // ✅ string | undefined — from MessageCtx
487
+ return next();
488
+ });
489
+ ```
490
+
491
+ #### `ContextOf<T>` — extract the current context type
492
+
493
+ Extracts `TOut` (the fully accumulated context type after all `.derive()` and `.decorate()` calls) from a Composer or EventComposer instance type.
494
+
495
+ **Naming a plugin's context type for reuse:**
496
+
497
+ ```ts
498
+ import type { ContextOf } from "@gramio/composer";
499
+
500
+ const withUser = new Composer()
501
+ .derive(async (ctx) => ({
502
+ user: await db.getUser(ctx.userId),
503
+ }));
504
+
505
+ // Extract the enriched context — no manual conditional type needed
506
+ export type WithUser = ContextOf<typeof withUser>;
507
+ // WithUser = { userId: string } & { user: User }
508
+
509
+ // Use it in standalone functions, other plugins, or type assertions:
510
+ function requireAdmin(ctx: WithUser) {
511
+ if (!ctx.user.isAdmin) throw new Error("Forbidden");
512
+ }
350
513
  ```
351
514
 
352
- 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`).
515
+ **In a custom method signature** `ContextOf<TThis>` captures all derives accumulated at the call site:
516
+
517
+ ```ts
518
+ command<TThis extends ComposerLike<TThis>>(
519
+ this: TThis,
520
+ handler: Middleware<ContextOf<TThis>>,
521
+ ): TThis
522
+ ```
523
+
524
+ #### `ComposerLike<T>` — minimal structural type for `this` constraints
525
+
526
+ 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.
527
+
528
+ ```ts
529
+ import type { ComposerLike } from "@gramio/composer";
530
+
531
+ // Constraint in a custom method:
532
+ command<TThis extends ComposerLike<TThis>>(this: TThis, ...): TThis {
533
+ return this.on("message", inner); // returns TThis — no `as TThis` needed
534
+ }
535
+ ```
353
536
 
354
537
  ### `EventQueue`
355
538
 
@@ -370,6 +553,88 @@ queue.addBatch(events);
370
553
  await queue.stop(5000);
371
554
  ```
372
555
 
556
+ ### Macro System
557
+
558
+ 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.
559
+
560
+ #### `macro(name, definition)` / `macro(definitions)`
561
+
562
+ Register macros on a Composer or EventComposer instance.
563
+
564
+ ```ts
565
+ import { Composer, type MacroDef, type ContextCallback } from "@gramio/composer";
566
+
567
+ // Boolean shorthand macro — plain hooks object
568
+ const onlyAdmin: MacroDef<void, {}> = {
569
+ preHandler: (ctx, next) => {
570
+ if (ctx.role !== "admin") return; // stops chain
571
+ return next();
572
+ },
573
+ };
574
+
575
+ // Parameterized macro — function receiving options
576
+ interface ThrottleOptions {
577
+ limit: number;
578
+ window?: number;
579
+ onLimit?: ContextCallback; // ← replaced with actual ctx type at call site
580
+ }
581
+
582
+ const throttle: MacroDef<ThrottleOptions, {}> = (opts) => ({
583
+ preHandler: createThrottleMiddleware(opts),
584
+ });
585
+
586
+ // Macro with derive — enriches handler context
587
+ interface AuthDerived { user: { id: number; name: string } }
588
+
589
+ const auth: MacroDef<void, AuthDerived> = {
590
+ derive: async (ctx) => {
591
+ const user = await getUser(ctx.token);
592
+ if (!user) return; // void = stop chain (guard behavior)
593
+ return { user };
594
+ },
595
+ };
596
+
597
+ const app = new Composer()
598
+ .macro("onlyAdmin", onlyAdmin)
599
+ .macro({ throttle, auth }); // batch registration
600
+ ```
601
+
602
+ #### `buildFromOptions(macros, options, handler)`
603
+
604
+ Runtime helper that composes a handler with macro hooks. Used internally by frameworks to wire macros into handler methods.
605
+
606
+ ```ts
607
+ import { buildFromOptions } from "@gramio/composer";
608
+
609
+ // Execution order:
610
+ // 1. options.preHandler[] (explicit guards — user controls order)
611
+ // 2. Per-macro in options property order:
612
+ // a. macro.preHandler (guard middleware)
613
+ // b. macro.derive (context enrichment; void = stop chain)
614
+ // 3. Main handler
615
+ const composed = buildFromOptions(
616
+ app["~"].macros,
617
+ { auth: true, throttle: { limit: 5 } },
618
+ mainHandler,
619
+ );
620
+ ```
621
+
622
+ #### Macro Types
623
+
624
+ ```ts
625
+ import type {
626
+ MacroDef, // Macro definition (function or hooks object)
627
+ MacroHooks, // { preHandler?, derive? }
628
+ MacroDefinitions, // Record<string, MacroDef<any, any>>
629
+ ContextCallback, // Marker type for context-aware callbacks
630
+ WithCtx, // Recursively replaces ContextCallback with real ctx type
631
+ HandlerOptions, // Builds the options parameter type for handler methods
632
+ DeriveFromOptions, // Collects derive types from activated macros
633
+ MacroOptionType, // Extracts option type from MacroDef
634
+ MacroDeriveType, // Extracts derive return type from MacroDef
635
+ } from "@gramio/composer";
636
+ ```
637
+
373
638
  ### Utilities
374
639
 
375
640
  ```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 });
@@ -296,8 +308,14 @@ class Composer {
296
308
  fn(group);
297
309
  const chain = compose(group["~"].middlewares.map((m) => m.fn));
298
310
  const mw = async (ctx, next) => {
299
- const scopedCtx = Object.create(ctx);
300
- await chain(scopedCtx, noopNext);
311
+ const preKeys = new Set(Object.keys(ctx));
312
+ const snapshot = {};
313
+ for (const key of preKeys) snapshot[key] = ctx[key];
314
+ await chain(ctx, noopNext);
315
+ for (const key of Object.keys(ctx)) {
316
+ if (!preKeys.has(key)) delete ctx[key];
317
+ }
318
+ Object.assign(ctx, snapshot);
301
319
  return next();
302
320
  };
303
321
  nameMiddleware(mw, "group");
@@ -316,6 +334,7 @@ class Composer {
316
334
  this["~"].extended.add(key);
317
335
  }
318
336
  Object.assign(this["~"].errorsDefinitions, other["~"].errorsDefinitions);
337
+ Object.assign(this["~"].macros, other["~"].macros);
319
338
  this["~"].onErrors.push(...other["~"].onErrors);
320
339
  const pluginName = other["~"].name;
321
340
  const isNew = (m) => {
@@ -331,8 +350,14 @@ class Composer {
331
350
  if (localMws.length > 0) {
332
351
  const chain = compose(localMws.map((m) => m.fn));
333
352
  const isolated = async (ctx, next) => {
334
- const scopedCtx = Object.create(ctx);
335
- await chain(scopedCtx, noopNext);
353
+ const preKeys = new Set(Object.keys(ctx));
354
+ const snapshot = {};
355
+ for (const key of preKeys) snapshot[key] = ctx[key];
356
+ await chain(ctx, noopNext);
357
+ for (const key of Object.keys(ctx)) {
358
+ if (!preKeys.has(key)) delete ctx[key];
359
+ }
360
+ Object.assign(ctx, snapshot);
336
361
  return next();
337
362
  };
338
363
  nameMiddleware(isolated, "extend", pluginName);
@@ -475,19 +500,36 @@ class EventQueue {
475
500
  }
476
501
  }
477
502
 
503
+ function defineComposerMethods(methods) {
504
+ return methods;
505
+ }
478
506
  function eventTypes() {
479
507
  return void 0;
480
508
  }
481
509
  function createComposer(config) {
482
510
  class EventComposerImpl extends Composer {
483
- on(event, handler) {
484
- const events = Array.isArray(event) ? event : [event];
511
+ on(eventOrFilter, filterOrHandler, handler) {
512
+ if (typeof eventOrFilter === "function") {
513
+ const filter2 = eventOrFilter;
514
+ const actualHandler2 = filterOrHandler;
515
+ const filterLabel = filter2.name || "filter";
516
+ const mw2 = (ctx, next) => {
517
+ if (filter2(ctx)) return actualHandler2(ctx, next);
518
+ return next();
519
+ };
520
+ nameMiddleware(mw2, "on", filterLabel);
521
+ this["~"].middlewares.push({ fn: mw2, scope: "local", type: "on", name: filterLabel });
522
+ this.invalidate();
523
+ return this;
524
+ }
525
+ const events = Array.isArray(eventOrFilter) ? eventOrFilter : [eventOrFilter];
485
526
  const eventLabel = events.join("|");
527
+ const actualHandler = handler ?? filterOrHandler;
528
+ const filter = handler ? filterOrHandler : void 0;
486
529
  const mw = (ctx, next) => {
487
- if (events.includes(config.discriminator(ctx))) {
488
- return handler(ctx, next);
489
- }
490
- return next();
530
+ if (!events.includes(config.discriminator(ctx))) return next();
531
+ if (filter && !filter(ctx)) return next();
532
+ return actualHandler(ctx, next);
491
533
  };
492
534
  nameMiddleware(mw, "on", eventLabel);
493
535
  this["~"].middlewares.push({ fn: mw, scope: "local", type: "on", name: eventLabel });
@@ -535,10 +577,52 @@ function createComposer(config) {
535
577
  };
536
578
  }
537
579
 
580
+ function buildFromOptions(macros, options, handler) {
581
+ if (!options) return handler;
582
+ const chain = [];
583
+ const preHandler = options.preHandler;
584
+ if (preHandler) {
585
+ if (Array.isArray(preHandler)) {
586
+ chain.push(...preHandler);
587
+ } else {
588
+ chain.push(preHandler);
589
+ }
590
+ }
591
+ for (const key of Object.keys(options)) {
592
+ if (key === "preHandler") continue;
593
+ const value = options[key];
594
+ if (value === false || value == null) continue;
595
+ const def = macros[key];
596
+ if (!def) continue;
597
+ const hooks = typeof def === "function" ? def(value === true ? void 0 : value) : def;
598
+ if (hooks.preHandler) {
599
+ if (Array.isArray(hooks.preHandler)) {
600
+ chain.push(...hooks.preHandler);
601
+ } else {
602
+ chain.push(hooks.preHandler);
603
+ }
604
+ }
605
+ if (hooks.derive) {
606
+ const deriveFn = hooks.derive;
607
+ chain.push(async (ctx, next) => {
608
+ const derived = await deriveFn(ctx);
609
+ if (derived == null) return;
610
+ Object.assign(ctx, derived);
611
+ return next();
612
+ });
613
+ }
614
+ }
615
+ chain.push(handler);
616
+ if (chain.length === 1) return chain[0];
617
+ return compose(chain);
618
+ }
619
+
538
620
  exports.Composer = Composer;
539
621
  exports.EventQueue = EventQueue;
622
+ exports.buildFromOptions = buildFromOptions;
540
623
  exports.compose = compose;
541
624
  exports.createComposer = createComposer;
625
+ exports.defineComposerMethods = defineComposerMethods;
542
626
  exports.eventTypes = eventTypes;
543
627
  exports.noopNext = noopNext;
544
628
  exports.skip = skip;