Integrations
Fastify
Fastify is the framework with the most server-side search behind it that this
package does not yet have a page for, and it is a different job from the
Hono page in one specific way: Hono is a fetch handler, so
handleReport mounts with no adapter at all, while Fastify predates the web
Request and needs one. That is what fastifyHandler is.
Every claim below was read out of [email protected]'s published build, not out
of fastify.dev.
Receiving a report #
import Fastify from "fastify";
import { fastifyHandler, fileStore, toWebhook } from "bugbottle/server";
const app = Fastify({ bodyLimit: 5 * 1024 * 1024 });
app.post(
"/api/bug-report",
fastifyHandler({
store: fileStore({ dir: "./reports", maxReports: 2000 }).store,
sinks: [toWebhook({ endpoint: process.env.SLACK_WEBHOOK_URL!, format: "slack" })],
}),
);fastifyHandler takes Fastify's own (request, reply) and hands back nothing:
it is a route handler, so the reply is the one Fastify already made, and the
status and headers handleReport chose are written through it. It reads a
request.body the content-type parser already produced, and the raw stream
when nothing parsed one, exactly as the Express adapter does. A raw stream is
counted against maxBodyBytes as it arrives; over the ceiling the adapter
answers 413 and destroys the request rather than buffering the rest of a body
it has already refused.
The default body limit is smaller than a report with a screenshot #
This is the finding on this page, and it is the one that decides whether the
snippet above works. bodyLimit defaults to 1 048 576 bytes — it is in
defaultInitOptions, and again as a schema default in config-validator.js —
and the JSON parser refuses anything larger with FST_ERR_CTP_BODY_TOO_LARGE
before the route handler is entered at all:
// lib/content-type-parser.js
const contentLength = Number(request.headers['content-length'])
if (contentLength > limit) {
done(new FST_ERR_CTP_BODY_TOO_LARGE(), undefined)
return
}So a 2 MB report — a screenshot plus a console ring buffer, which is ordinary —
is a 413 with Fastify's error shape, fastifyHandler never runs, and the
message the reporter sees is not one of bugbottle's. Meanwhile handleReport
would have accepted the same body: its own ceiling is DEFAULT_MAX_BODY_BYTES,
4 MiB, four times as large. The default is not wrong for a JSON API, and the
mismatch is silent — every report under a megabyte arrives, so a route looks
healthy until the first person attaches a picture.
bodyLimit: 5 * 1024 * 1024 in the snippet is the fix, and it is worth reading
as what it is: an adapter that silently serves a subset of the reports the
client is willing to send. The same applies to maxBodyBytes on the handler,
which is the ceiling the adapter enforces itself once the body is in hand.
A signed route cannot work behind the default parser #
The signature covers the exact text the browser sent. Fastify registers
application/json in the ContentTypeParser constructor, so by the time the
handler runs, request.body is an object, and re-serialising it gives
different bytes and a different HMAC — every signed report is answered 401,
forever, for a reason that has nothing to do with the sender.
Unlike Express, there is no way to route around it by not mounting a parser,
because this one is the framework's own and always runs. The way out is a
parser that keeps the text, and fastifyHandler takes that result unchanged:
app.addContentTypeParser("application/json", { parseAs: "string" },
(_req, body, done) => done(null, body));A string is not re-serialised, so the bytes that are verified are the bytes
that were signed. As with the Express adapter, a mounting mistake and a forged
signature look identical on the wire, so the adapter answers the same 401 and
says so once through onError — once per handler, because it is a mounting
mistake and not an event.
setErrorHandler replaces the logger, and the logger is not the console #
setErrorHandler is how Fastify reports a server-side crash, and what it
replaces is the whole of the framework's own error output:
function defaultErrorHandler (error, request, reply) {
setErrorHeaders(error, reply)
setErrorStatusCode(reply, error)
request.server[kLogController].defaultErrorLog(error, request, reply)
reply.send(error)
}Two things follow, and the first is the one nobody expects.
There is no console.error to lose. console.error, console.warn and
console.log appear zero times across [email protected]'s lib/ and
fastify.js. Everything goes through defaultErrorLog, which writes to
reply.log — pino, with pino's own formatting and pino's own destination. A
handler that replaces the default does not silence the terminal, it moves the
output somewhere else, and where depends on your logger configuration rather
than on Fastify. A pino transport writing to pino-pretty in development and
to stdout in production is the usual answer, and "usual" is the problem: a
Fastify app that nobody configured logs somewhere the other half of the tooling
cannot see.
A handler that returns something sends it. handleError does
if (result !== undefined) { … reply.send(result) }, so returning a value from
setErrorHandler is how you answer with something other than the error, and
returning a promise routes it through wrapThenable — which means an async
handler that rejects is handled by Fastify like any other async failure. This
is the fifth framework in a row where the documented handler replaces the
error output rather than adding to it (Vue's errorHandler, Nuxt's
app:error, React's onCaughtError, Hono's onError, now this), and the
pattern is worth a line of its own in whichever handler you write.
app.setErrorHandler((err, request, reply) => {
// Keep the framework's own log line. This is what it logged.
request.log.error({ err }, err.message);
// …then report it, with a timeout and a caught promise.
});An error thrown inside setErrorHandler is caught, not crashed #
handleError wraps the call in try/catch and sends the new error, and
before it does it walks the handler up one prototype:
reply[kReplyNextErrorHandler] = Object.getPrototypeOf(errorHandler)buildErrorHandler builds each scope's handler with Object.create(parent), so
a handler that throws falls to its parent scope's handler — the enclosing
plugin, then the instance's. In an application built from encapsulated plugins
that is a genuinely useful property: a reporter registered on the root catches
a bug in a route registered in a plugin, because the walk ends at the root.
The same line is why a throwing handler in the root scope does not loop: the
parent there is rootErrorHandler, whose func is undefined, which is the
fallbackErrorHandler branch.
The sharp edge is the same as everywhere else: an async handler that rejects reports the rejection, not the error it was given. Await inside, and catch.
Which address the rate limit counts #
The adapter passes request.raw.socket.remoteAddress — the connection — and
only falls back to request.ip. That order is the same as the Express
adapter's, and for a sharper reason here: Fastify only defines request.ip
at all when the instance set trustProxy. buildRequest returns a plain
buildRegularRequest otherwise, and the ip, ips, host and protocol
getters are added by buildRequestWithTrustProxy. So on a default instance
request.ip is undefined — which is the honest answer, and the reason the
socket has to be first rather than a preference.
If you do set trustProxy, request.ip is already a forwarded address, so
reading it would trust a setting trustProxy was never asked about. Pass
remoteAddress explicitly when you want to decide, and trustProxy in
handleReport when you want the package to read forwarding headers for you.
The URL this adapter builds for handleReport follows the same rule:
x-forwarded-proto decides the scheme only when trustProxy says a proxy may
speak for this deployment, and only https is believed out of it. The
Express page says why in full, and the two answer alike — they are
two translations of one request, and the URL is what a log reads as fact.
What to check before you ship #
bodyLimitraised aboveDEFAULT_MAX_BODY_BYTES, or send a report with a screenshot and look at what comes back. This is the one that fails in production only.- A signed route, if you have one, sending one report and getting a
201back rather than a401. request.log.errorstill in yoursetErrorHandler— and checked where your pino output actually goes, not in the source.- An async
setErrorHandlerthat catches its own failure. - The rate limit counting a real address: two reports from one browser should
be one allowed and one
429.