@yaebal/router

file-based routing — load commands/, on/, hears/, and use/ handlers from a routes/ directory by convention, typed end to end via define*() helpers. a route file's default export is never a bare handler — it's a value only defineCommand() / defineOn() / defineHears() / defineUse() can produce, so loadRoutes can validate the trigger (a typo'd update type or an invalid command name throws at load time, with a "did you mean" where it can help) instead of registering a handler nothing will ever call.

install

terminal
pnpm add @yaebal/router

directory convention

place route files under four sub-directories inside your routes folder. nesting inside any of them is purely organizational (and guardable — see below): a nested command or event still registers by whatever the route itself declares, not by its path.

routes/
routes/
  use/
    logger.ts            # → bot.use(handler) — mounted first, in file order
  commands/
    start.ts              # → defineCommand("start", ...)
    admin/
      _guard.ts            # → defineGuard(...) gates everything under admin/
      ban.ts               # → defineCommand("ban", ...) — still just "/ban", nesting is organizational
  hears/
    ping.ts                # → defineHears("ping", ...)
  on/
    message.text.ts        # → defineOn("message:text", ...) — dots become ":"
    callback_query.data.ts

files under on/ use . as a filename separator for the query, since : isn't a legal filename character on every os — dots become : automatically (message.text.ts"message:text"). files starting with _ are never treated as routes; _guard.ts is the one reserved name router looks for.

route files

each kind has its own define*() helper, mirroring the matching Composer method (command() / on() / hears() / use()) — including its context narrowing.

routes/commands/start.ts
// routes/commands/start.ts
import { defineCommand } from "@yaebal/router";

export default defineCommand("start", { description: "start the bot" }, async (ctx) => {
  await ctx.reply(`welcome! args: ${ctx.args.join(", ")}`);
});
routes/on/message.text.ts
// routes/on/message.text.ts
import { defineOn } from "@yaebal/router";

export default defineOn("message:text", async (ctx) => {
  await ctx.reply(`you said: ${ctx.text}`); // ctx.text: string — narrowed, not optional
});
routes/hears/ping.ts
// routes/hears/ping.ts
import { defineHears } from "@yaebal/router";

export default defineHears("ping", async (ctx) => {
  await ctx.reply(`pong (matched: ${ctx.match})`);
});
routes/use/logger.ts
// routes/use/logger.ts
import { defineUse } from "@yaebal/router";

export default defineUse((ctx, next) => {
  console.log(`update #${ctx.update.update_id}`);
  return next();
});

use/ is the escape hatch for anything the other three don't fit: a plain middleware file, several handlers of different kinds in one file, or a whole standalone Composer (defineUse(someComposer) — it's collapsed via toMiddleware()).

usage

call loadRoutes(bot, dir) once after constructing the bot. it scans every sub-directory, validates and imports each file, and registers routes in a fixed order — usecommandshearson, regardless of file-system order — then returns what it registered.

bot.ts
import { Bot } from "@yaebal/core";
import { loadRoutes } from "@yaebal/router";
import { fileURLToPath } from "node:url";

const bot = new Bot(process.env.BOT_TOKEN!);
const routesDir = fileURLToPath(new URL("./routes", import.meta.url));

const result = await loadRoutes(bot, routesDir);
console.log(result.routes.map((r) => `${r.kind}:${r.trigger}`));
// ["use:logger", "command:start", "command:ban", "hears:ping", "on:message:text", ...]

bot.start();

typed context with createRouter()

the bare defineCommand etc. default to the plain Context. bind them once to your bot's own accumulated context — derive()/decorate() extras included — with createRouter<ContextOf<typeof bot>>(), and import the bound helpers from route files instead of the bare package.

router.ts + a route file
// routes/router.ts — bind define*() to your bot's own accumulated context once
import type { ContextOf } from "@yaebal/router";
import { createRouter } from "@yaebal/router";
import { bot } from "../bot.js";

export const { defineCommand, defineOn, defineHears, defineUse, defineGuard } =
  createRouter<ContextOf<typeof bot>>();

// routes/commands/start.ts — import the bound helper instead of the bare package
import { defineCommand } from "../router.js";

export default defineCommand("start", async (ctx) => {
  // sees every derive()/decorate() field this bot has, not just the bare Context
  await ctx.reply(ctx.session ? "welcome back" : "welcome");
});

nested guards

a _guard.ts default-exporting defineGuard(predicate) gates every route in its own directory and every subdirectory beneath it, across all four kinds. a route nested several levels deep collects every guard from its directory up to the routes root, evaluated outermost first — every one of them has to pass.

routes/commands/admin/_guard.ts
// routes/commands/admin/_guard.ts
import { defineGuard } from "@yaebal/router";

export default defineGuard((ctx) => ctx.from?.id === ADMIN_ID);
// gates every route under commands/admin/ — a failing guard stops the update right there,
// exactly like Composer.guard(): nothing registered after it runs for that update
a broken _guard.ts — one that fails to import, or doesn't default-export defineGuard(...) — always throws, even under strict: false. a guard is access control; silently downgrading a broken one to "no guard" would be a security regression, not a lint warning.

ordering

directory scans are sorted (natural, numeric-aware), so registration order never depends on the filesystem's own listing order. give a file a numeric prefix (10-first.ts, 20-second.ts) to control its position within a directory — the prefix is stripped from the trigger/label, it's ordering only.

validation

loadRoutes checks every route before registering anything: the default export must come from a define*() helper, an on() query's update type must be real (checked against @yaebal/core's own updateNames, with a "did you mean" for close typos), a command name must match telegram's [a-z0-9_]{1,32}, and no two files may claim the same trigger. a route whose file name doesn't match its own declared trigger (copy-paste drift) is always just a warning — the route still registers as declared.

strict vs. non-strict
// strict (the default): a bad route throws at load time, before the bot ever starts —
// nothing silently registers a handler that can never match.
await loadRoutes(bot, routesDir);
// Error: @yaebal/router: "mesage" is not a telegram update type — did you mean "message"?
//   (in on-query "mesage:text") (in on/mesage.text.js)

// strict: false collects the same failures as warnings instead, and keeps loading the rest —
// handy while iterating with watchRoutes(), where a mid-edit file is expected, not exceptional.
const result = await loadRoutes(bot, routesDir, { strict: false });
result.warnings;
// [{ message: '"mesage" is not a telegram update type — did you mean "message"? ...', file: "on/mesage.text.js" }]

the bot's command menu

LoadResult.commands is always a ready BotCommand[] built from every defineCommand's meta.description — router only ever reads (name, description, scope) for this, never handlers: the handlers are wired by loadRoutes itself regardless of these options, so nothing double-registers.

menu.ts
// bare — LoadResult.commands is a ready BotCommand[] for the default menu
// (menu-visible: has a meta.description, not hidden, no scope — scoped/localized menus need
// the bridge below)
const result = await loadRoutes(bot, routesDir);
await bot.api.call("setMyCommands", { commands: result.commands });

// full power (scopes, locales, diff-aware sync) via the optional @yaebal/commands peer dependency
import { commands } from "@yaebal/commands";

const registry = commands();
await loadRoutes(bot, routesDir, { commands: registry });
// add your own scoped/localized entries too, then push everything in one sync
registry.scoped({ type: "all_chat_administrators" }).add("audit", "view the audit log");
await registry.sync(bot.api);

// or let router run the sync itself — no separate @yaebal/commands import needed
await loadRoutes(bot, routesDir, { syncCommands: true });          // uses target.api
await loadRoutes(bot, routesDir, { syncCommands: { api: otherApi } });

dev hot-reload with watchRoutes()

watchRoutes(bot, dir, options) behaves like loadRoutes for its first build (a bad route still throws, same as loadRoutes would), then keeps watching dir and swaps in the new route set on every change — no process restart. it mounts exactly one stable middleware (Composer/Bot can't drop a middleware once added) that delegates to a rebuildable off-bot registry, so an update already in flight finishes against whichever route set was live when it started.

dev entrypoint
import { watchRoutes } from "@yaebal/router";

// dev only — keeps watching routesDir and hot-swaps the route set on every change, no restart
const stop = await watchRoutes(bot, routesDir, {
  onReload: (result) => console.log("routes reloaded:", result.routes.length),
  onError: (error) => console.error("route reload failed, keeping the last good set:", error),
});

process.once("SIGINT", async () => {
  await stop();
  await bot.stop();
});
strict defaults to false under watchRoutes (the opposite of loadRoutes): a route file mid-edit is expected during development, not exceptional — it becomes a warning delivered via onReload instead of killing the watcher. a live rebuild that still throws (a broken _guard.ts, or an explicit strict: true) reports through onError and keeps the previous, last-good route set mounted.

dev only. cache-busted re-imports leak the previous module instance on every reload (node has no way to unload an es module), and the fallback watcher re-scans the directory tree on every change. use loadRoutes in production.

testing

with @yaebal/test a routed bot is driven the same way any other bot is — loadRoutes runs once before createTestEnv wraps it:

router.test.ts
import { createTestEnv } from "@yaebal/test";
import { Bot } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!);
await loadRoutes(bot, routesDir);

const env = createTestEnv(bot);
await env.createUser().sendCommand("start");
env.lastApiCall("sendMessage"); // assert the reply from routes/commands/start.ts

api

exportsignaturedescription
loadRoutes(target, dir, options?: LoadOptions) => Promise<LoadResult>scan, validate, and register every route — see LoadOptions/LoadResult below
watchRoutes(target, dir, options?: WatchOptions) => Promise<() => Promise<void>>like loadRoutes, plus dev hot-reload — returns a disposer
defineCommand(name | [name, ...aliases], meta?, ...handlers) => RouteDefa commands/ route — ctx.command/args/payload, menu-visible via meta.description
defineOn<Q extends FilterQuery>(query: Q, ...handlers) => RouteDefan on/ route, narrowed exactly like Composer.on(query, ...)
defineHears(trigger: string | RegExp, ...handlers) => RouteDefa hears/ route — exposes ctx.match
defineUse(...items: (Composer | Middleware)[]) => RouteDefa use/ route — a standalone composer or bare middleware
defineGuard(predicate: (ctx) => boolean | Promise<boolean>) => RouteDefa _guard.ts route — gates its directory and subdirectories
createRouter<C extends Context>() => RouterHelpers<C>every define* helper, bound to C
ContextOftype ContextOf<T> = T extends Composer<infer C> ? C : neverextracts a bot's accumulated context type — ContextOf<typeof bot>
RouteTargetinterfacethe minimal surface a router needs — satisfied by Bot/Composer

LoadOptions

fieldtypedescription
strictbooleanthrow on validation failures instead of warning — default true (loadRoutes) / false (watchRoutes)
extensionsstring[]module extensions to import — default [".js", ".mjs", ".cjs", ".ts"]
commandsCommandsRegistryLikefeed menu-visible commands into your own @yaebal/commands registry
syncCommandsboolean | { api }diff-sync the menu to telegram via the optional @yaebal/commands peer dependency

LoadResult

fieldtypedescription
routesRegisteredRoute[]{ kind, trigger, aliases?, file }[], in registration order
commandsBotCommand[]the default-menu commands — see "the bot's command menu" above
warningsRouterWarning[]{ message, file? }[] — populated under strict: false, plus filename/trigger mismatches always

see the modular-router example for all four route kinds, a nested guard, the menu bridge, and watchRoutes in one runnable bot, and @yaebal/commands for the full registry api (scopes, locales, aliases, sync()/register()) behind the optional menu bridge.