Files
redefined-designs/backend/src/routes/clientErrors.ts
T
bermudalambandClaude Opus 5 71cbd142c3 fix: address the final review of the error boundaries (#62)
Five findings from a whole-branch review, two of which mattered.

The catalogue boundary could not be recovered by the very controls it exists to keep alive. When the item grid threw, the header, filter chips and Clear filters stayed mounted — that was the point — but clicking Clear filters only changed the URL. A boundary does not reset on a client-side state change, so the fallback went on rendering over a catalogue that would by then have loaded perfectly well, and the only offered action reloaded the same failing URL. The shop read as permanently broken, which is the failure this whole change exists to prevent, reintroduced one level down. ErrorBoundary now takes an optional resetKey and clears itself when it changes; the catalogue boundary passes the filter key. The page and modal boundaries deliberately do not take one, because their escapes are hard navigations that remount the tree already — recorded on the prop so nobody completes the pattern by symmetry.

The client-error endpoint could fill the disk. It is unauthenticated, each accepted report wrote about 8.7 KB, and Docker's default json-file driver has no size cap — so the rate limiter bounded a render loop, as its comment claims, but not a few hundred cheap source addresses. Stack and component stack now truncate at 1000 rather than 4000, which is still around fifteen frames and cuts the worst case to under 3 KB, and the QA compose file caps and rotates the log. Production is a Portainer stack outside this repository and needs the same option applied there; noted in the design doc rather than left implied.

Three smaller things. A falsy thrown value defeated the boundary entirely: throw null is legal, and branching on the error object alone treated it as no error, re-rendered the children, threw again, and would have taken the root down — a blank page, the one outcome this is all here to avoid. The boundary now tracks hasError separately and synthesises a real Error for non-Error throws. The modal fallback announced its title twice to a screen reader, once as the dialog's name and once as the heading inside it, so the redundant dialog title is gone. And the design doc claimed the development-only detail shows the component stack when it only ever showed the message; corrected, with a note that the stack still reaches the server log, which is where it is useful.

Verified after all five: backend lint 0 errors, 144 integration tests, frontend lint 0 errors and 31 warnings, 87 end-to-end tests, all against a freshly created database.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00

82 lines
3.5 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { clientErrorLimiter } from '../rateLimit';
const router = Router();
// The three error boundaries in the frontend. An unrecognised context means the
// client and the server disagree about something, which is worth surfacing
// rather than logging under a guessed label — the same reasoning as
// parseItemFilters refusing a malformed filter instead of coercing it.
const CONTEXTS: readonly string[] = ['page', 'catalogue', 'modal'];
const MAX_MESSAGE = 500;
// Kept well short of the 4000 this endpoint originally used. The endpoint is
// unauthenticated and the rate limiter is per-address, so a distributed
// writer sending a few hundred cheap requests a day was still tens of
// megabytes against Docker's default json-file log driver, which has no size
// cap on its own. 1000 characters is roughly fifteen stack frames — enough to
// identify a throw — and keeps the worst-case record under 3 KB.
const MAX_STACK = 1000;
const MAX_COMPONENT_STACK = 1000;
const MAX_PATH = 200;
// True for CR, LF and every other C0 control character, plus DEL (the
// C0 range is code points 0 through 31; DEL is 127). Written as a numeric
// comparison rather than a control-character regex literal so the source
// never has to embed a raw control character or an escape sequence for one.
const LAST_C0_CODE = 31;
const DEL_CODE = 127;
function isControlCharCode(code: number): boolean {
return code <= LAST_C0_CODE || code === DEL_CODE;
}
// Strips CR, LF and other control characters from a string, replacing each
// with a single space. The endpoint is unauthenticated, so without this a
// caller could embed a newline in any field to forge what looks like a
// second [client-error] line in the shared server log. The replacement is
// 1-for-1 (one control character becomes one space), so it cannot change
// the string's length either way.
function sanitize(value: string): string {
let result = '';
for (const char of value) {
result += isControlCharCode(char.codePointAt(0) ?? 0) ? ' ' : char;
}
return result;
}
// Anything that is not a string becomes empty rather than 'undefined' or
// '[object Object]', so a malformed field cannot dress itself up as content.
function clip(value: unknown, max: number): string {
if (typeof value !== 'string') {
return '';
}
// Sanitize before truncating, not after. Because the substitution above is
// 1-for-1, sanitizing first cannot push the stored length past `max` — an
// escaping scheme that expanded a control character into multiple visible
// characters would need the opposite order to keep that same guarantee, so
// the two are not interchangeable and must not be reordered without
// re-checking this.
const sanitized = sanitize(value);
return sanitized.length > max ? `${sanitized.slice(0, max)}… [truncated]` : sanitized;
}
// No asyncRoute: this handler is synchronous, so there is no promise for the
// error middleware to miss.
router.post('/', clientErrorLimiter, (req: Request, res: Response) => {
const context: unknown = req.body?.context;
if (typeof context !== 'string' || !CONTEXTS.includes(context)) {
return res.status(400).json({ error: 'invalid context' });
}
console.error(
`[client-error] context=${context} path=${clip(req.body?.path, MAX_PATH)}\n` +
` message: ${clip(req.body?.message, MAX_MESSAGE)}\n` +
` stack: ${clip(req.body?.stack, MAX_STACK)}\n` +
` componentStack: ${clip(req.body?.componentStack, MAX_COMPONENT_STACK)}`
);
res.status(204).end();
});
export default router;