@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
pnpm add @yaebal/routerdirectory 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/
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.tsfiles 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
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
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
import { defineHears } from "@yaebal/router";
export default defineHears("ping", async (ctx) => {
await ctx.reply(`pong (matched: ${ctx.match})`);
});// 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 — use → commands → hears → on, regardless of
file-system order — then returns what it registered.
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.
// 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
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_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 (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.
// 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.
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:
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.tsapi
| export | signature | description |
|---|---|---|
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) => RouteDef | a commands/ route — ctx.command/args/payload, menu-visible via meta.description |
defineOn | <Q extends FilterQuery>(query: Q, ...handlers) => RouteDef | an on/ route, narrowed exactly like Composer.on(query, ...) |
defineHears | (trigger: string | RegExp, ...handlers) => RouteDef | a hears/ route — exposes ctx.match |
defineUse | (...items: (Composer | Middleware)[]) => RouteDef | a use/ route — a standalone composer or bare middleware |
defineGuard | (predicate: (ctx) => boolean | Promise<boolean>) => RouteDef | a _guard.ts route — gates its directory and subdirectories |
createRouter | <C extends Context>() => RouterHelpers<C> | every define* helper, bound to C |
ContextOf | type ContextOf<T> = T extends Composer<infer C> ? C : never | extracts a bot's accumulated context type — ContextOf<typeof bot> |
RouteTarget | interface | the minimal surface a router needs — satisfied by Bot/Composer |
LoadOptions
| field | type | description |
|---|---|---|
strict | boolean | throw on validation failures instead of warning — default true (loadRoutes) / false (watchRoutes) |
extensions | string[] | module extensions to import — default [".js", ".mjs", ".cjs", ".ts"] |
commands | CommandsRegistryLike | feed menu-visible commands into your own @yaebal/commands registry |
syncCommands | boolean | { api } | diff-sync the menu to telegram via the optional @yaebal/commands peer dependency |
LoadResult
| field | type | description |
|---|---|---|
routes | RegisteredRoute[] | { kind, trigger, aliases?, file }[], in registration order |
commands | BotCommand[] | the default-menu commands — see "the bot's command menu" above |
warnings | RouterWarning[] | { 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.