Integrations
NestJS
NestJS is the sixth framework in this row and the first one where the honest
answer is that the framework is on the wrong side of both jobs. A
BaseExceptionFilter runs on the server: it holds a Request, and it has
never seen the console ring buffer, the DOM, the screenshot or the sentence a
person typed. So a Nest filter is not where you collect a report. It is where
you forward a server crash, and a report forwarded from there is a report
about the server. The browser side is whichever framework serves your HTML, or
the one script tag — this page is about the receiving end,
which is the half that is actually Nest.
Every claim below was read out of @nestjs/[email protected] and
@nestjs/[email protected]'s published builds, not out of
docs.nestjs.com.
Receiving a report #
Nest's default platform is Express, so @Req() and @Res() hand this
package's own Express adapter the two objects it already knows how to read.
There is no nestHandler, and there does not need to be one:
import { Controller, Post, Req, Res } from "@nestjs/common";
import { expressHandler, fileStore, toWebhook } from "bugbottle/server";
import type { ExpressRequestLike, ExpressResponseLike } from "bugbottle/server";
const receive = expressHandler({
store: fileStore({ dir: "./reports", maxReports: 2000 }).store,
sinks: [toWebhook({ endpoint: process.env.SLACK_WEBHOOK_URL!, format: "slack" })],
});
@Controller("api/bug-report")
export class ReportController {
@Post()
async store(@Req() req: ExpressRequestLike, @Res() res: ExpressResponseLike) {
receive(req, res);
}
}Two details are load-bearing. Injecting @Res() tells Nest you are answering
the request yourself, so Nest does not write a response of its own — which is
what lets the adapter's status, headers and body stand. And receive returns
void: it starts the work and returns, so awaiting it would only be waiting
for a function that has already handed off.
The default body limit is 100 kB, forty times too small #
This is the finding on this page, and it is the same shape as Fastify's, only smaller. Nest does not choose a limit, so it inherits body-parser's:
// @nestjs/platform-express/adapters/utils/get-body-parser-options.util.js
function getBodyParserOptions(rawBody, options) {
let parserOptions = (options || {});
if (rawBody === true) parserOptions = { ...parserOptions, verify: rawBodyParser };
return parserOptions;
}No limit is set anywhere on that path, so express.json() is registered with
body-parser's own default — 102400 // 100kb default in lib/utils.js. A
report with a screenshot is a base64 PNG inside a JSON body and is routinely
two megabytes, so it is refused by the parser before your controller is
entered, with body-parser's entity.too.large error. http-errors sets both
err.status and err.statusCode, so BaseExceptionFilter's isHttpError
recognises it and Nest answers 413 with { statusCode: 413, message: "request entity too large" } — a real and correct 413, and one that never reaches
handleReport, whose own ceiling is DEFAULT_MAX_BODY_BYTES, 4 MiB, forty
times as large.
Every report under 100 kB arrives, so the route looks healthy until the first
person attaches a picture. The fix is Nest's own re-registration, called before
listen():
app.useBodyParser("json", { limit: "5mb" });useBodyParser is the same use call with the same options
registerParserMiddleware would have made, so it replaces the parser rather
than adding a second one. With @nestjs/platform-fastify there is no such
method — the adapter logs "Your HTTP adapter does not support
.useBodyParser" and moves on — which is why the Fastify
page is its own page and this one is not.
A signed route needs rawBody, and rawBody is not enough #
The signature covers the exact text the browser sent, and Nest's parser has
already turned it into an object by the time your controller runs — so
expressHandler re-serialises it, the bytes differ, and every signed report is
answered 401 for a reason that has nothing to do with the sender. A mounting
mistake and a forged signature look identical on the wire.
Nest's answer is rawBody: true, which is better than Fastify's because there
is nothing to register:
const app = await NestFactory.create(AppModule, { rawBody: true });getBodyParserOptions adds a verify that keeps the bytes on req.rawBody,
and useBodyParser threads the same flag through, so the limit and the raw
body can be set together. The route then has to hand those bytes to
handleReport itself, which is six lines, because the adapter reads req.body
and a parsed object is not what a signature covers:
@Post()
async store(@Req() req: RawBodyRequest<Request>, @Res() res: Response) {
const web = new Request(`http://${req.headers.host}${req.url}`, {
method: "POST",
headers: req.headers as HeadersInit,
body: req.rawBody,
});
const answer = await handleReport(web, {
signature: { key: process.env.SIGN_KEY! },
});
res.status(answer.status);
answer.headers.forEach((value, name) => res.setHeader(name, value));
res.send(await answer.text());
}new Request(url, { body: buffer }) takes a Buffer as BodyInit, so there
is no stream to bridge, and this is the one route in this package where
building the Request by hand is the right answer rather than the last resort.
A filter with no @Catch() argument catches everything, and wins #
Reading a filter as "the one that handles my error type" gets it backwards twice, and both halves are in one line:
// @nestjs/common/utils/select-exception-filter-metadata.util.js
filters.find(({ exceptionMetatypes }) =>
!exceptionMetatypes.length || exceptionMetatypes.some((M) => exception instanceof M));A filter with no @Catch() argument has an empty exceptionMetatypes, so
!exceptionMetatypes.length is true and it matches every exception. find
returns the first match, so a bare @Catch() filter makes every specific
filter declared before it dead code — silently, with no warning. And the array
it searches is filters.reverse() in RouterExceptionFilters.create, with
global filters ahead of scoped ones, so the last filter registered wins.
The rule that follows is the opposite of the one people carry over from
middleware: the specific filter first, the catch-all last.
app.useGlobalFilters(new HttpExceptionFilter(), new EverythingFilter());getArgByIndex(1) is a parameter, not the response #
BaseExceptionFilter is not handed a response; it goes looking for one:
const response = host.getArgByIndex(1);
if (!applicationRef.isHeadersSent(response)) {
applicationRef.reply(response, message, exception.getStatus());
}For the framework's own filters, index 1 is the response, because Nest hands
those [req, res, next]. For a filter attached to one route method, index
1 is that method's second parameter — so extending BaseExceptionFilter on a
handler taking (id: string) reaches for a string, and isHeadersSent("123")
is the truthy answer that then goes into reply. If you extend the base
filter, the handler has to declare @Res().
Two more things about the base class, both worth knowing before extending it.
BaseExceptionFilter.logger is a static —
BaseExceptionFilter.logger = new Logger("ExceptionsHandler") — so the line
that logs a crash, if (!(exception instanceof IntrinsicException)) BaseExceptionFilter.logger.error(exception), always logs under the name
ExceptionsHandler whatever your class is called, and you cannot point it at
your own logger; a subclass that wants its own output overrides catch and
logs for itself. And ExternalExceptionFilter, the one used outside the HTTP
context, logs and then rethrows — so a reporting filter that sends no
answer of its own leaves the exception travelling on, and the reporter's own
status never arrives. Send it, or let it through; never neither.
Which address the rate limit counts #
Pass req.socket.remoteAddress in as remoteAddress, exactly as the
Express page says. Nest's @Req() is that same Express request,
and Nest has its own trust proxy setting on the adapter, which is a
different one from handleReport's. Decide with one of them, not with both.