UNPKG

raw-body

Version:

Get and validate the raw body of a readable stream.

463 lines (462 loc) 14.9 kB
/*! * raw-body * Copyright(c) 2013-2014 Jonathan Ong * Copyright(c) 2014-2022 Douglas Christopher Wilson * MIT Licensed */ import { AsyncResource } from 'node:async_hooks'; import bytes from 'bytes'; import createError from 'http-errors'; // Error.isError is not yet in the TypeScript lib const nativeIsError = Error.isError; const isError = typeof nativeIsError === 'function' ? nativeIsError : function (err) { return err instanceof Error; }; /** * Check for the node readable stream interface. */ function isNodeReadable(stream) { return typeof stream.on === 'function'; } /** * Check for the web ReadableStream interface. */ function isWebReadable(stream) { return typeof stream.getReader === 'function'; } /** * Get the decoder for a given encoding. */ function getDecoder(encoding, createDecoder) { if (!encoding) return null; try { if (createDecoder) return createDecoder(encoding); const decoder = new TextDecoder(encoding); return { write(chunk) { return decoder.decode(chunk, { stream: true }); }, end() { return decoder.decode(); } }; } catch { // the encoding was not found throw createError(415, 'specified encoding unsupported', { encoding, type: 'encoding.unsupported' }); } } /** * Create a 413 entity too large error. The properties differ * between the early length check and the streamed limit check. */ function entityTooLargeError(props) { props.type = 'entity.too.large'; return createError(413, 'request entity too large', props); } /** * Create a 400 request aborted error. */ function abortedError(length, received, cause) { const err = createError(400, 'request aborted', { code: 'ECONNABORTED', expected: length, length, received, type: 'request.aborted' }); if (cause !== undefined) { err.cause = cause; } return err; } /** * Create a 400 size mismatch error. */ function sizeMismatchError(length, received) { return createError(400, 'request size did not match content length', { expected: length, length, received, type: 'request.size.invalid' }); } /** * Create a 500 not readable error. */ function notReadableError() { return createError(500, 'stream is not readable', { type: 'stream.not.readable' }); } /** * Create a 500 encoding set error. */ function encodingSetError() { return createError(500, 'stream encoding should not be set', { type: 'stream.encoding.set' }); } /** * Validate the total received length and assemble the body. * * @param total exact byte count, when known */ function finish(decoder, buffer, length, received, done, total) { if (length !== null && received !== length) { return done(sizeMismatchError(length, received)); } let string; try { if (decoder) { string = buffer + (decoder.end() || ''); } else { const chunks = buffer; const first = chunks[0]; // a body delivered in a single chunk, the common case for // small bodies, is handed over as-is instead of copied string = chunks.length === 1 ? (Buffer.isBuffer(first) ? first : Buffer.from(first.buffer, first.byteOffset, first.byteLength)) : Buffer.concat(chunks, total); } } catch (err) { return done(err); } done(null, string); } /** * Validate the options and read the stream, shared by `getRawBody` * and `getRawBodyWeb`. The reader is passed in, without allocating * an intermediate closure per call. */ function getBody(read, stream, options, callback) { let done = callback; let opts = (options || {}); if (options === true || typeof options === 'string') { // short cut for encoding opts = { encoding: options }; } if (typeof options === 'function') { done = options; opts = {}; } // validate callback is a function, if provided if (done !== undefined && typeof done !== 'function') { throw new TypeError('argument callback must be a function'); } // get encoding, treating any falsy value as "no decoding" const encoding = opts.encoding === true ? 'utf-8' : (opts.encoding || null); // validate decoder is a function, if provided if (opts.decoder !== undefined && typeof opts.decoder !== 'function') { throw new TypeError('option decoder must be a function'); } // convert the limit to an integer const limit = opts.limit == null ? null : bytes.parse(opts.limit); // an unparseable limit is a developer error: silently reading // with no limit at all would disable the protection if (opts.limit != null && limit === null) { throw new TypeError('option limit must be a number of bytes or a byte size string'); } // convert the expected length to an integer. const parsedLength = opts.length != null && !Number.isNaN(Number(opts.length)) ? parseInt(String(opts.length), 10) : NaN; const length = Number.isNaN(parsedLength) ? null : parsedLength; const decoder = opts.decoder; if (done) { // classic callback style read(stream, encoding, length, limit, decoder, bindAsyncContext(done)); return; } return new Promise(function executor(resolve, reject) { read(stream, encoding, length, limit, decoder, function onRead(err, buf) { if (err) return reject(err); resolve(buf); }); }); } function getRawBody(stream, options, callback) { // light validation. if (stream === undefined) { throw new TypeError('argument stream is required'); } if (typeof stream !== 'object' || stream === null || !isNodeReadable(stream)) { throw new TypeError('argument stream must be a node stream'); } return getBody(readStream, stream, options, callback); } function getRawBodyWeb(stream, options, callback) { // light validation. if (stream === undefined) { throw new TypeError('argument stream is required'); } if (typeof stream !== 'object' || stream === null || !isWebReadable(stream)) { throw new TypeError('argument stream must be a web ReadableStream'); } return getBody(readWebStream, stream, options, callback); } export default getRawBody; export { getRawBody, getRawBodyWeb }; /** * Halt a stream. */ function halt(stream) { // unpipe everything from the stream stream.unpipe(); // pause stream stream.pause(); } /** * Read the data from the stream. */ function readStream(stream, encoding, length, limit, createDecoder, callback) { let buffer; let complete = false; let sync = true; // check the length and limit options. // note: we intentionally leave the stream paused, // so users should handle the stream themselves. if (limit !== null && length !== null && length > limit) { return done(entityTooLargeError({ expected: length, length, limit })); } // assert the stream encoding is buffer. if (stream.readableEncoding) { // developer error return done(encodingSetError()); } if (typeof stream.readable !== 'undefined' && !stream.readable) { return done(notReadableError()); } let received = 0; let decoder; try { decoder = getDecoder(encoding, createDecoder); } catch (err) { return done(err); } buffer = decoder ? '' : []; // attach listeners stream.on('aborted', onAborted); stream.on('close', onClose); stream.on('data', onData); stream.on('end', onEnd); stream.on('error', onEnd); // mark sync section complete sync = false; function done(...args) { // mark complete complete = true; if (sync) { process.nextTick(invokeCallback); } else { invokeCallback(); } function invokeCallback() { cleanup(); if (args[0]) { // halt the stream on error halt(stream); } callback.apply(null, args); } } function onAborted() { if (complete) return; done(abortedError(length, received)); } function onClose() { if (complete) return cleanup(); // the stream was destroyed before finishing, without emitting // an error: surface an aborted request, like the web path, // instead of never settling done(abortedError(length, received)); } function onData(chunk) { if (complete) return; // string chunks mean the stream is already decoded: the // readableEncoding assertion covers real streams if (typeof chunk === 'string') { return done(encodingSetError()); } received += chunk.length; if (limit !== null && received > limit) { done(entityTooLargeError({ limit, received })); } else if (length !== null && received > length) { // past the declared length: a size mismatch, reported now like // finish() does at stream end, instead of buffering the rest done(sizeMismatchError(length, received)); } else if (decoder) { try { buffer += decoder.write(chunk); } catch (err) { done(err); } } else { buffer.push(chunk); } } function onEnd(err) { if (complete) return; if (err) return done(err); // received is an exact byte count, since string chunks // are rejected on this path finish(decoder, buffer, length, received, done, received); } function cleanup() { buffer = null; stream.removeListener('aborted', onAborted); stream.removeListener('data', onData); stream.removeListener('end', onEnd); stream.removeListener('error', onEnd); stream.removeListener('close', onClose); } } /** * View a chunk as a Buffer, for a decoder that expects one. */ function toBuffer(chunk) { return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); } /** * Read the data from a web ReadableStream. */ function readWebStream(stream, encoding, length, limit, createDecoder, callback) { let buffer; let reader = null; // check the length and limit options. // note: on error the reader lock is released but the stream is // not cancelled, so users should handle the stream themselves. if (limit !== null && length !== null && length > limit) { return fail(entityTooLargeError({ expected: length, length, limit })); } // reject streams locked to another reader. note: cancelled or // fully-read streams cannot be detected portably (no runtime-agnostic // access to the disturbed flag) and read as an empty body if (stream.locked) { return fail(notReadableError()); } let received = 0; let decoder; try { decoder = getDecoder(encoding, createDecoder); } catch (err) { return fail(err); } buffer = decoder ? '' : []; reader = stream.getReader(); read(); function read() { // not .catch: onError must only see read() rejections, // never throws from the user callback inside onRead reader.read().then(onRead, onError); } function onError(err) { // map aborts (undici's AbortError, node http's ECONNRESET // 'aborted') like the node path; other resets pass through if (err && (err.name === 'AbortError' || (err.code === 'ECONNRESET' && err.message === 'aborted'))) { return done(abortedError(length, received, err)); } if (isError(err)) { return done(err); } // a web stream may error with any value, or none at all: // normalize, so callers always get an Error done(new Error('stream error', { cause: err })); } function fail(err) { // defer, so the callback is never invoked synchronously process.nextTick(done, err); } function done(err, string) { buffer = null; if (reader) { // release the stream, so users can handle the rest themselves reader.releaseLock(); } callback(err, string); } function onRead(result) { if (result.done) { // received is an exact byte count on this path return finish(decoder, buffer, length, received, done, received); } const value = result.value; // a stream of strings is already decoded, so decoding it // again with the declared encoding would corrupt the data if (decoder && typeof value === 'string') { return done(encodingSetError()); } let chunk; if (typeof value === 'string') { // a string-emitting stream (e.g. TextDecoderStream) is already // text: encode it back to utf-8 bytes chunk = Buffer.from(value); } else if (value instanceof Uint8Array) { // kept as-is, not wrapped in a Buffer: concat and the decoder // both accept a Uint8Array chunk = value; } else { return done(new TypeError('stream chunks must be Uint8Array or string')); } received += chunk.byteLength; if (limit !== null && received > limit) { done(entityTooLargeError({ limit, received })); } else if (length !== null && received > length) { // past the declared length: a size mismatch, reported now like // finish() does at stream end, instead of buffering the rest done(sizeMismatchError(length, received)); } else { try { if (decoder) { buffer += decoder.write(toBuffer(chunk)); } else { // held and assembled at the end, trusting the producer not // to reuse the chunk's memory, like the node path buffer.push(chunk); } } catch (err) { return done(err); } read(); } } } function bindAsyncContext(fn) { const resource = new AsyncResource(fn.name || 'bound-anonymous-fn'); return resource.runInAsyncScope.bind(resource, fn, null); }