@yaebal/web

run your bot on any runtime via webhooks — edge (cloudflare, deno, bun, vercel), node servers (express, fastify, koa), serverless (aws lambda, azure, google cloud functions), and fetch frameworks (hono, elysia, next.js, sveltekit)

install

terminal
pnpm add @yaebal/web

usage

no long-polling: each incoming Request becomes one update. webhook(bot, options?) returns a standard (Request, execution?) => Promise<Response> that drops straight into any fetch runtime. the optional second argument is the platform context (cloudflare's ctx) — pass it so a slow update can finish via waitUntil.

worker.ts
import { Bot } from "@yaebal/core";
import { webhook } from "@yaebal/web";

// cloudflare workers / deno deploy / vercel edge / bun — no long-polling, just fetch
export default {
  fetch(request: Request, env: { BOT_TOKEN: string; SECRET: string }, ctx) {
    const bot = getBot(env); // build once per isolate, not per request
    return webhook(bot, { secretToken: env.SECRET })(request, ctx);
  },
};

pass botInfo to the Bot constructor on serverless to skip the getMe cold-start round trip; otherwise the handler resolves it lazily on the first update, so ctx.me and /cmd@botname addressing work without start().

framework adapters

for frameworks whose handler shape isn't (Request) => Response, an adapter wraps the same handler. every adapter forwards the secret token and request path, so secretToken and path behave identically across all of them — and each is zero-node:-import (node bodies are read through the stream interface).

adapters.ts
import { expressAdapter, fastifyAdapter, honoAdapter, awsLambdaAdapter } from "@yaebal/web";

// express / google cloud functions / firebase
app.post("/", expressAdapter(bot, { secretToken: SECRET }));

// fastify
fastify.post("/", fastifyAdapter(bot, { secretToken: SECRET }));

// hono — also threads c.executionCtx.waitUntil
app.post("/", honoAdapter(bot, { secretToken: SECRET }));

// aws lambda (api gateway / function url)
export const handler = awsLambdaAdapter(bot, { secretToken: SECRET });
adapterruntime / framework
honoAdapterhono — wires waitUntil
elysiaAdapterelysia
cloudflareAdaptercloudflare workers module syntax (request, env, ctx)
nextAdapternext.js app-router route handler (remix, astro too)
svelteKitAdaptersveltekit endpoint — reads platform.context
expressAdapter / gcfAdapterexpress, google cloud functions, firebase
fastifyAdapterfastify
koaAdapterkoa
awsLambdaAdapteraws lambda behind api gateway / function url
azureAdapterazure functions (v3 model)

all of them are also grouped on the adapters object — adapters.express(bot).

standalone server (node / bun / deno)

serve(bot, options?) starts the runtime's native http server and resolves to a handle you can stop(). it works on node too (lazily importing node:http), so there's no per-runtime special-casing. on an edge platform with no server to own, export { fetch: webhook(bot) } instead.

server.ts
import { serve } from "@yaebal/web";

// node, bun, or deno — the native http server, no per-runtime branching
const server = await serve(bot, { port: 8080, secretToken: process.env.SECRET });
process.once("SIGINT", () => server.stop());
console.log(`listening on ${server.url}`);

production hardening

telegram fires webhook updates in parallel (up to maxConnections) and redelivers on failure. these two combinators are what a serious deployment needs:

hardening.ts
import { sequentialize, dedupe } from "@yaebal/web";

// telegram delivers webhook updates in parallel — order them per chat so
// sessions/scenes/conversations don't clobber each other. install this FIRST.
bot.use(sequentialize());

// drop the updates telegram redelivers when a request fails or times out.
bot.use(dedupe());

for defence in depth, isTelegramIp(ip) checks the peer against telegram's published subnets — pass the client ip your platform exposes (cf-connecting-ip, x-forwarded-for, req.ip).

webhook options

options.ts
webhook(bot, {
  secretToken: SECRET,      // require the X-Telegram-Bot-Api-Secret-Token header
  path: "/telegram",        // only serve this path; others hit fallback / 404
  fallback: () => new Response("ok"), // health checks, GET probes
  timeoutMs: 10_000,        // answer anyway after 10s so telegram won't redeliver
  onTimeout: "ack",         // "ack" (200, keep running) | "fail" (500, redeliver)
  onError: "fail",          // "fail" (500, redeliver) | "ack" (200, drop)
  maxBodyBytes: 1 << 20,    // streaming-enforced body cap (1 MiB default)
  reply: true,              // answer the webhook request with an api call (saves a round trip)
});

register & diagnose

tell telegram where to send updates. the secretToken you set here must match the one you pass to webhook() / serve() — it's validated locally and checked on every request.

deploy.ts
import { setWebhook, getWebhookInfo, deleteWebhook } from "@yaebal/web";

// run once on deploy to point telegram at your url
await setWebhook(bot, "https://my-worker.workers.dev/", {
  secretToken: process.env.SECRET,
  allowedUpdates: ["message", "callback_query"],
  dropPendingUpdates: true,
  maxConnections: 40,
});

// diagnose stalled deliveries — pending_update_count, last_error_message, …
const info = await getWebhookInfo(bot);

// switch back to long-polling later
await deleteWebhook(bot, { dropPendingUpdates: true });

api

exportdescription
webhook(bot, options?)fetch handler (Request, execution?) => Promise<Response> for edge/web runtimes
serve(bot, options?)standalone http server on node/bun/deno; resolves to { url, port, stop() }
honoAdapter, expressAdapter, …per-framework handlers (see table above); also on the adapters object
sequentialize(key?)middleware: order updates per chat (or custom key)
dedupe(options?)middleware: drop redelivered update_ids
isTelegramIp(ip)is the peer in a telegram webhook subnet?
setWebhook(bot, url, options?)register the webhook (secret, allowed updates, ip, certificate, …)
deleteWebhook(bot, options?)remove the webhook; accepts { dropPendingUpdates } or a bare boolean
getWebhookInfo(bot)current status: pending count, last error, url
node needs no special path. serve() runs everywhere; the package is fetch-first with zero static node: imports, so it still bundles for edge. for a hand-rolled node server you can also use nodeWebhookCallback from @yaebal/core/node. the operator dashboard lives in @yaebal/panel.