@oxog/spark
Version:
Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling
526 lines (468 loc) • 14.5 kB
JavaScript
/**
* @fileoverview Body parsing middleware for Spark Framework
* @author Spark Framework Team
* @since 1.0.0
* @version 1.0.0
*/
const querystring = require('querystring');
const { Readable } = require('stream');
/** @constant {number} Default size limit for request bodies (1MB) */
const DEFAULT_LIMIT = 1024 * 1024; // 1MB
/** @constant {boolean} Default extended parsing setting */
const DEFAULT_EXTENDED = true;
/**
* Create a body parsing middleware
*
* Automatically parses request bodies based on Content-Type header.
* Supports JSON, URL-encoded, multipart/form-data, text, and raw binary data.
* Provides security features like size limits and content validation.
*
* @param {Object} [options={}] - Body parser configuration options
* @param {number} [options.limit=1048576] - Maximum request body size in bytes (default: 1MB)
* @param {boolean} [options.extended=true] - Use extended URL encoding parsing
* @param {string} [options.type='auto'] - Force parsing type ('auto', 'json', 'urlencoded', 'multipart', 'text', 'raw')
* @param {string} [options.encoding='utf8'] - Text encoding for parsing
* @param {boolean} [options.inflate=true] - Enable gzip/deflate decompression
* @param {boolean} [options.strict=true] - Enable strict parsing
* @param {Function} [options.verify] - Custom verification function
* @returns {Function} Express-style middleware function
*
* @since 1.0.0
*
* @example
* // Auto-detect content type
* app.use(bodyParser());
*
* @example
* // JSON only with custom limit
* app.use(bodyParser({
* type: 'json',
* limit: 1024 * 512 // 512KB
* }));
*
* @example
* // With verification
* app.use(bodyParser({
* verify: (req, res, body) => {
* if (body.length > 1000) {
* throw new Error('Body too large');
* }
* }
* }));
*
* @example
* // Multiple content types
* app.use(bodyParser({ type: 'json' }));
* app.use(bodyParser({ type: 'urlencoded' }));
* app.use(bodyParser({ type: 'text' }));
*/
function bodyParser(options = {}) {
const opts = {
limit: options.limit || DEFAULT_LIMIT,
extended: options.extended !== false,
type: options.type || 'auto',
encoding: options.encoding || 'utf8',
inflate: options.inflate !== false,
strict: options.strict !== false,
verify: options.verify,
...options
};
return async (ctx, next) => {
if (ctx.body !== null || ctx.method === 'GET' || ctx.method === 'HEAD') {
return next();
}
const contentType = ctx.get('content-type') || '';
const contentLength = parseInt(ctx.get('content-length')) || 0;
if (contentLength > opts.limit) {
ctx.status(413).json({ error: 'Request entity too large' });
return;
}
try {
if (shouldParseJson(contentType, opts.type)) {
await parseJson(ctx, opts);
} else if (shouldParseUrlencoded(contentType, opts.type)) {
await parseUrlencoded(ctx, opts);
} else if (shouldParseMultipart(contentType, opts.type)) {
await parseMultipart(ctx, opts);
} else if (shouldParseText(contentType, opts.type)) {
await parseText(ctx, opts);
} else if (shouldParseRaw(contentType, opts.type)) {
await parseRaw(ctx, opts);
}
if (opts.verify) {
await Promise.resolve(opts.verify(ctx.req, ctx.res, ctx.body));
}
await next();
} catch (error) {
ctx.status(400).json({
error: 'Bad Request',
message: error.message,
type: error.name
});
}
};
}
/**
* Check if content should be parsed as JSON
*
* @param {string} contentType - Request Content-Type header
* @param {string} type - Parser type setting
* @returns {boolean} Whether to parse as JSON
*
* @private
* @since 1.0.0
*/
function shouldParseJson(contentType, type) {
if (type === 'json') return true;
if (type === 'auto') return contentType.includes('application/json');
return false;
}
/**
* Check if content should be parsed as URL-encoded
*
* @param {string} contentType - Request Content-Type header
* @param {string} type - Parser type setting
* @returns {boolean} Whether to parse as URL-encoded
*
* @private
* @since 1.0.0
*/
function shouldParseUrlencoded(contentType, type) {
if (type === 'urlencoded') return true;
if (type === 'auto') return contentType.includes('application/x-www-form-urlencoded');
return false;
}
/**
* Check if content should be parsed as multipart
*
* @param {string} contentType - Request Content-Type header
* @param {string} type - Parser type setting
* @returns {boolean} Whether to parse as multipart
*
* @private
* @since 1.0.0
*/
function shouldParseMultipart(contentType, type) {
if (type === 'multipart') return true;
if (type === 'auto') return contentType.includes('multipart/form-data');
return false;
}
/**
* Check if content should be parsed as text
*
* @param {string} contentType - Request Content-Type header
* @param {string} type - Parser type setting
* @returns {boolean} Whether to parse as text
*
* @private
* @since 1.0.0
*/
function shouldParseText(contentType, type) {
if (type === 'text') return true;
if (type === 'auto') return contentType.includes('text/');
return false;
}
/**
* Check if content should be parsed as raw binary
*
* @param {string} contentType - Request Content-Type header
* @param {string} type - Parser type setting
* @returns {boolean} Whether to parse as raw binary
*
* @private
* @since 1.0.0
*/
function shouldParseRaw(contentType, type) {
return type === 'raw';
}
/**
* Parse request body as JSON
*
* @param {Context} ctx - Request context
* @param {Object} opts - Parser options
* @returns {Promise<void>}
*
* @throws {Error} When JSON parsing fails
*
* @private
* @since 1.0.0
*/
async function parseJson(ctx, opts) {
const body = await readBody(ctx.req, opts);
try {
ctx.body = JSON.parse(body);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
}
/**
* Parse request body as URL-encoded form data
*
* @param {Context} ctx - Request context
* @param {Object} opts - Parser options
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async function parseUrlencoded(ctx, opts) {
const body = await readBody(ctx.req, opts);
ctx.body = querystring.parse(body);
}
async function parseMultipart(ctx, opts) {
const contentType = ctx.get('content-type');
const boundary = getBoundary(contentType);
if (!boundary) {
throw new Error('Invalid multipart/form-data: missing boundary');
}
const chunks = [];
let size = 0;
await new Promise((resolve, reject) => {
const onData = (chunk) => {
size += chunk.length;
if (size > opts.limit) {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('end', onEnd);
ctx.req.removeListener('error', onError);
return reject(new Error('Request entity too large'));
}
chunks.push(chunk);
};
const onEnd = () => {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('error', onError);
resolve();
};
const onError = (error) => {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('end', onEnd);
reject(error);
};
ctx.req.on('data', onData);
ctx.req.on('end', onEnd);
ctx.req.on('error', onError);
});
const body = Buffer.concat(chunks).toString('binary');
const parsed = parseMultipartData(body, boundary);
ctx.body = parsed.fields;
ctx.files = parsed.files;
}
/**
* Parse request body as plain text
*
* @param {Context} ctx - Request context
* @param {Object} opts - Parser options
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async function parseText(ctx, opts) {
ctx.body = await readBody(ctx.req, opts);
}
/**
* Parse request body as raw binary data
*
* @param {Context} ctx - Request context
* @param {Object} opts - Parser options
* @returns {Promise<void>}
*
* @throws {Error} When request is too large
*
* @private
* @since 1.0.0
*/
async function parseRaw(ctx, opts) {
const chunks = [];
return new Promise((resolve, reject) => {
let size = 0;
const onData = (chunk) => {
size += chunk.length;
if (size > opts.limit) {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('end', onEnd);
ctx.req.removeListener('error', onError);
return reject(new Error('Request entity too large'));
}
chunks.push(chunk);
};
const onEnd = () => {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('error', onError);
ctx.body = Buffer.concat(chunks);
resolve();
};
const onError = (error) => {
ctx.req.removeListener('data', onData);
ctx.req.removeListener('end', onEnd);
reject(error);
};
ctx.req.on('data', onData);
ctx.req.on('end', onEnd);
ctx.req.on('error', onError);
});
}
/**
* Read and decode request body as text
*
* @param {http.IncomingMessage} req - HTTP request object
* @param {Object} opts - Parser options with encoding and limit
* @returns {Promise<string>} Promise resolving to decoded body text
*
* @throws {Error} When request is too large
*
* @private
* @since 1.0.0
*/
function readBody(req, opts) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const onData = (chunk) => {
size += chunk.length;
if (size > opts.limit) {
req.removeListener('data', onData);
req.removeListener('end', onEnd);
req.removeListener('error', onError);
return reject(new Error('Request entity too large'));
}
chunks.push(chunk);
};
const onEnd = () => {
req.removeListener('data', onData);
req.removeListener('error', onError);
const body = Buffer.concat(chunks).toString(opts.encoding || 'utf8');
resolve(body);
};
const onError = (error) => {
req.removeListener('data', onData);
req.removeListener('end', onEnd);
reject(error);
};
req.on('data', onData);
req.on('end', onEnd);
req.on('error', onError);
});
}
/**
* Extract boundary from multipart Content-Type header
*
* @param {string} contentType - Content-Type header value
* @returns {string|null} Boundary string or null if not found
*
* @private
* @since 1.0.0
*/
function getBoundary(contentType) {
const match = contentType.match(/boundary=([^;]+)/);
if (!match || !match[1]) return null;
// Remove quotes if present
return match[1].replace(/^["']|["']$/g, '');
}
/**
* Parse multipart/form-data body content
*
* @param {string} body - Raw multipart body as binary string
* @param {string} boundary - Multipart boundary string
* @returns {Object} Object with fields and files properties
* @returns {Object} returns.fields - Parsed form fields
* @returns {Object} returns.files - Parsed file uploads with metadata
*
* @private
* @since 1.0.0
*/
function parseMultipartData(body, boundary) {
const fields = {};
const files = {};
const parts = body.split(`--${boundary}`);
for (let i = 1; i < parts.length - 1; i++) {
const part = parts[i];
const [headers, content] = part.split('\r\n\r\n');
if (!headers || content === undefined) continue;
const headerLines = headers.split('\r\n');
const contentDisposition = headerLines.find(line =>
line.startsWith('Content-Disposition:')
);
if (!contentDisposition) continue;
const nameMatch = contentDisposition.match(/name="([^"]+)"/);
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
if (!nameMatch) continue;
const name = nameMatch[1];
const filename = filenameMatch ? filenameMatch[1] : null;
if (filename) {
const contentType = headerLines.find(line =>
line.startsWith('Content-Type:')
);
files[name] = {
filename,
contentType: contentType ? contentType.split(': ')[1] : 'application/octet-stream',
size: Buffer.byteLength(content.slice(0, -2)),
data: Buffer.from(content.slice(0, -2))
};
} else {
fields[name] = content.slice(0, -2);
}
}
return { fields, files };
}
/**
* Create a JSON body parser middleware
*
* @param {Object} [options] - Parser options (merged with type: 'json')
* @returns {Function} Middleware function for parsing JSON bodies
*
* @since 1.0.0
*
* @example
* app.use(json({ limit: '10mb' }));
*/
function json(options) {
return bodyParser({ ...options, type: 'json' });
}
/**
* Create a URL-encoded body parser middleware
*
* @param {Object} [options] - Parser options (merged with type: 'urlencoded')
* @returns {Function} Middleware function for parsing URL-encoded bodies
*
* @since 1.0.0
*
* @example
* app.use(urlencoded({ extended: true }));
*/
function urlencoded(options) {
return bodyParser({ ...options, type: 'urlencoded' });
}
/**
* Create a text body parser middleware
*
* @param {Object} [options] - Parser options (merged with type: 'text')
* @returns {Function} Middleware function for parsing text bodies
*
* @since 1.0.0
*
* @example
* app.use(text({ encoding: 'utf16' }));
*/
function text(options) {
return bodyParser({ ...options, type: 'text' });
}
/**
* Create a raw binary body parser middleware
*
* @param {Object} [options] - Parser options (merged with type: 'raw')
* @returns {Function} Middleware function for parsing raw binary bodies
*
* @since 1.0.0
*
* @example
* app.use(raw({ limit: '5mb' }));
*/
function raw(options) {
return bodyParser({ ...options, type: 'raw' });
}
module.exports = bodyParser;
module.exports.json = json;
module.exports.urlencoded = urlencoded;
module.exports.text = text;
module.exports.raw = raw;