- Raw body bytes:
ingest()gets the exactBufferthat arrived. Signatures such as Meta’sX-Hub-Signature-256are computed over those bytes, never over re-serialized JSON. - Size limit (
413), 1 MiB by default, checked againstContent-Lengthand while the stream arrives. - Timeouts for reading the body (
408) and foringest()(503). - Concurrency limit (
503+Retry-After: 1when saturated). - Routing:
GET→verifyChallenge()(Meta’s handshake),POST→ingest(raw, headers, url). The valueingest()returns is the HTTP status. - Health endpoints:
/healthz(liveness) and/readyz(readiness). - No sensitive logs: only method, path, status, sizes and the error class name; never bodies, headers, query strings or error messages.
- No framework dependencies: adapters for
node:http, Express, Fastify, Hono and fetch-style runtimes, typed structurally.
With node:http
createNodeHandler(ingress, options) answers 404 for paths it does not
serve; pass onNotFound: (req, res) => ... to chain another handler
instead.
Express
next(). Paths are matched
against req.url, so they are relative to where the middleware is mounted.
If a body parser already consumed the body (express.json() mounted
earlier), the ingress answers 500 instead of re-serializing. Behind
express.raw() it reuses that Buffer (and still applies maxBodyBytes).
Fastify
Hono and fetch runtimes
serveFetch(ingress, request) is the lower-level variant: it resolves
undefined when no route matches, so you can fall through. The Hono adapter
needs Buffer (Node, Bun, Deno, Workers with nodejs_compat).
Options
createIngress(options):
Route rules:
path must start with /, a trailing slash is ignored, and
duplicate paths (a webhook path equal to a health path, or healthPath
equal to readyPath) throw a TypeError. A route whose provider has no
ingest() method, or whose body is not 'buffer' or 'text', also
throws. So do a non-positive limit, a timeout above 2147483647 ms (the
largest delay setTimeout honours; a bigger one would fire after 1 ms), a
bot without getConnectionState() and a readiness that is not a
function. Every message starts with @botmesh/server: and names the option.
The defaults are exported as DEFAULT_MAX_BODY_BYTES,
DEFAULT_BODY_TIMEOUT_MS, DEFAULT_INGEST_TIMEOUT_MS and
DEFAULT_MAX_CONCURRENT.
body: 'buffer' or 'text'
ingest() receives the raw body as a Buffer (default) or as a UTF-8
string with body: 'text'. TelegramProvider takes either (its first
alphas needed body: 'text').
Per-route size limit
RaisemaxBodyBytes on the routes that need it, for example raw MIME for
EmailProvider.ingest():
Responses
EmailProvider.ingest() resolves { status, accepted, reason? }, so its
status (200, 400 or 413) is used as is.
With node:http, Express and Fastify, before answering with
Connection: close (413, 503…) the rest of the unread body is discarded so
the client gets the response instead of an ECONNRESET. Draining is bounded
(8 MiB and 5 s by default;
createNodeHandler(ingress, { drain: { maxBytes, timeoutMs } })); past the
bounds the response is still written and the socket is reset. A 408 does
not wait for the body.
Health endpoints
Both acceptGET and HEAD only (405 otherwise):
/healthz: always200{"status":"ok"}while the process serves./readyz:200when ready,503otherwise. The JSON body is{ status: 'ready' | 'not_ready', connection?, draining? }:connectionis the bot’s connection state (whenbotis set) anddrainingappears afterclose().
close() has not been called, the bot (if any) is
'ready', and readiness() (if any) resolved true.
The Ingress object
close() without timeoutMs waits until every in-flight request settles;
a timeoutMs outside 0-2147483647 rejects with a TypeError. close() is
one-way: build a new ingress to serve again.
Provider notes
The ingress callsprovider.ingest(rawBody, headers, requestUrl), where
requestUrl is the request path with its query string (/hook?token=…),
exactly as received (not re-encoded, so a URL signature such as Twilio’s
still matches). Providers that authenticate a token in the URL read it from there:
Without the ingress, mount a route in your framework and pass the URL
yourself:
provider.ingest(body, headers, req.originalUrl) (WPPConnect,
Gupshup, and Twilio with webhookPath) or
provider.ingestRequest({ url, body, headers }) (Gupshup, and Twilio with the
full public URL) (see Channels).
Lower-level exports
serveNode(ingress, req, res, options): serve one Node request (used by the adapters).serveFetch(ingress, request): serve a WHATWGRequest.readNodeBody(req, limit, timeoutMs)/readWebBody(request, limit, timeoutMs): read a raw body with a byte limit and a timeout.BodyTooLargeError,BodyTimeoutError,BodyUnavailableError,BodyAbortedError: the errors those readers reject with.normalizePath(path),splitUrl(url): path helpers, in@botmesh/server/internal(no semver promise).
See also
- Channels: which providers are webhook-based.
- Deployment
@botmesh/serverREADME