// Bounded request-body reading for the API boundary (issue #16). // // Three limits have to fail before any provider work starts: the byte ceiling, a deadline for a body // that never arrives, and JSON validity. All three refuse with a client-visible status and a fixed // message; none of them logs or returns the body. const COPY = Object.freeze({ too_large: [413, 'Request is too large.'], invalid_json: [400, 'Invalid JSON request.'], body_timeout: [408, 'The request took too long to arrive. Try again.'], }); export class BodyError extends Error { constructor(code) { const [status, message] = COPY[code] || COPY.invalid_json; super(message); this.name = 'BodyError'; this.code = code; this.status = status; } } export function readJsonBody(req, { maxBytes = 6 * 1024 * 1024, timeoutMs = 15_000 } = {}) { return new Promise((resolve, reject) => { let settled = false; let tooLarge = false; let size = 0; const chunks = []; const finish = callback => { if (settled) return; settled = true; clearTimeout(deadline); req.removeListener('data', onData); req.removeListener('end', onEnd); req.removeListener('error', onError); callback(); }; const deadline = setTimeout(() => { finish(() => { // Stop reading and let the caller answer 408; the server destroys the socket once the // refusal is written, so a half-read body cannot be held open indefinitely. req.pause?.(); reject(new BodyError('body_timeout')); }); }, timeoutMs); function onData(chunk) { if (settled) return; // Past the ceiling the body is already refused: keep draining it without buffering so the // caller can still read the 413. Destroying the socket here would surface as "fetch failed" // and leave the caller with no status to act on. if (tooLarge) return; size += chunk.length; if (size > maxBytes) { tooLarge = true; chunks.length = 0; return; } chunks.push(chunk); } function onEnd() { finish(() => { if (tooLarge) return reject(new BodyError('too_large')); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new BodyError('invalid_json')); } }); } function onError(error) { finish(() => reject(error)); } req.on('data', onData); req.on('end', onEnd); req.on('error', onError); }); }