docs: design for React error boundaries (#62) #83

Merged
bermudalamb merged 6 commits from feature/62-error-boundary into main 2026-08-20 17:46:27 -05:00
2 changed files with 114 additions and 1 deletions
Showing only changes of commit 7d227507b0 - Show all commits
+32 -1
View File
@@ -14,13 +14,44 @@ const MAX_STACK = 4000;
const MAX_COMPONENT_STACK = 4000;
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 '';
}
return value.length > max ? `${value.slice(0, max)}… [truncated]` : value;
// 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
@@ -68,4 +68,86 @@ describe('POST /api/client-errors', () => {
expect(logged).toContain('[truncated]');
expect(logged.length).toBeLessThan(2000);
});
// The boundary itself, not just "way over the limit": a message of exactly
// MAX_MESSAGE (500) must survive untouched.
it('leaves a message of exactly the length limit unmodified', async () => {
const message = 'a'.repeat(500);
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain(`message: ${message}\n`);
expect(logged).not.toContain('[truncated]');
});
// One character past the boundary must truncate.
it('truncates a message one character past the length limit', async () => {
const message = 'a'.repeat(501);
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
});
it('truncates an oversized stack independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
stack: 'x'.repeat(4001)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
expect(stackLine).toContain('[truncated]');
});
it('truncates an oversized componentStack independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
componentStack: 'x'.repeat(4001)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const componentStackLine = logged
.split('\n')
.find((line) => line.trim().startsWith('componentStack:'));
expect(componentStackLine).toContain('[truncated]');
});
it('truncates an oversized path independently of message', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'short',
path: '/'.concat('x'.repeat(201))
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
const pathLine = logged.split('\n')[0];
expect(pathLine).toContain('[truncated]');
});
// The endpoint is unauthenticated, so nothing stops a caller from sending a
// message crafted to look like a second [client-error] line. This is what
// Finding 1 closes: an embedded CR/LF must not survive into the log.
it('collapses embedded newlines so a report cannot forge a second log line', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'modal',
message: 'real error\n[client-error] context=page path=/fake\r\n message: forged entry'
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
// The template itself joins four fixed lines with three newlines; that
// count must not grow no matter what the caller sends.
expect(logged.split('\n')).toHaveLength(4);
expect(logged).not.toContain('\n[client-error]');
expect(logged).not.toContain('\r');
});
});