From 7f87ad9081daf321c5ecec87ac4461195ad3b130 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 21 Aug 2026 02:34:50 +0200 Subject: [PATCH] perf: replace regex parser with a charCode scanner Rewrite the parser as a hand-rolled charCodeAt scanner driven by a single 64KB Uint8Array character-class table, replacing the mediaTypeRE/paramRE regular expressions. parse and safeParse now share one internal parseHeader that returns sentinel objects on failure, so there is no duplicated logic and no try/catch. Behaviour is unchanged: the scanner reproduces the exact grammar accepted by the previous regexes (including their quirks, which are documented in the code), verified by differential fuzzing against the previous implementation over ~19M inputs with zero mismatches. Error messages, result shape, null-prototype parameters and the frozen defaultContentType identity are all preserved. Throughput on Node 24 (ops/sec, before -> after): application/json 13.9M -> 18.0M (1.3x) application/json; charset=utf-8 2.76M -> 4.87M (1.8x) application/json; charset="utf-8" 2.46M -> 4.89M (2.0x) multipart/form-data; boundary=----WebKit... 2.52M -> 3.43M (1.4x) text/html; charset=utf-8; foo=bar; baz="qu\"x" 0.99M -> 2.13M (2.2x) Also adds tests for the previously untested error paths (empty key, missing "=", empty token value, bad/unterminated quoted-pair, Unicode whitespace around the media type) and fixes the README benchmark command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BHGGooWAqjNjuTqBMiytQU --- README.md | 16 ++- index.js | 332 ++++++++++++++++++++++++++++++++------------- test/index.test.js | 46 ++++++- 3 files changed, 285 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 0c0efda..14716de 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,15 @@ with an empty string `''` as type and an empty Object for `parameters`. ## Benchmarks ```sh -node benchmarks/index.js -util#MIMEType x 1,206,781 ops/sec ±0.22% (96 runs sampled) -fast-content-type-parse#parse x 3,752,236 ops/sec ±0.42% (96 runs sampled) -fast-content-type-parse#safeParse x 3,675,645 ops/sec ±1.09% (94 runs sampled) -content-type#parse x 1,452,582 ops/sec ±0.37% (95 runs sampled) -busboy#parseContentType x 924,306 ops/sec ±0.43% (94 runs sampled) -Fastest is fast-content-type-parse#parse +npm run benchmark + +Benchmarking: "application/json; charset=utf-8" +util#MIMEType x 2,637,188 ops/sec ±0.95% (93 runs sampled) +fast-content-type-parse#parse x 5,165,077 ops/sec ±0.75% (95 runs sampled) +fast-content-type-parse#safeParse x 5,189,599 ops/sec ±0.72% (94 runs sampled) +content-type#parse x 4,227,069 ops/sec ±0.79% (96 runs sampled) +busboy#parseContentType x 777,787 ops/sec ±0.75% (91 runs sampled) +Fastest is fast-content-type-parse#safeParse,fast-content-type-parse#parse ``` ## Credits diff --git a/index.js b/index.js index 438ef1e..f3b3a01 100644 --- a/index.js +++ b/index.js @@ -3,160 +3,298 @@ const NullObject = function NullObject () { } NullObject.prototype = Object.create(null) +const SP = 0x20 // ' ' +const SEMI = 0x3b // ';' +const EQ = 0x3d // '=' +const SLASH = 0x2f // '/' +const DQUOTE = 0x22 // '"' +const BSLASH = 0x5c // '\' + /** - * RegExp to match *( ";" parameter ) in RFC 7231 sec 3.1.1.1 + * Character class lookup table, indexed by UTF-16 code unit. It covers the + * whole code unit range so that lookups are never out of bounds and always + * yield a small integer, which keeps the scanning loops on V8's fast path. + * + * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + * / DIGIT / ALPHA + * ; any VCHAR, except delimiters + * + * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + * obs-text = %x80-FF * - * parameter = token "=" ( token / quoted-string ) - * token = 1*tchar - * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" - * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" - * / DIGIT / ALPHA - * ; any VCHAR, except delimiters - * quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE - * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text - * obs-text = %x80-FF - * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + * MEDIA_TYPE_TCHAR intentionally omits "`" and QDTEXT accepts VT (0x0b) + * rather than HTAB, to keep the behaviour of the regular expressions that + * were previously used for validation. */ -const paramRE = /; *([!#$%&'*+.^\w`|~-]+)=("(?:[\v\u0020\u0021\u0023-\u005b\u005d-\u007e\u0080-\u00ff]|\\[\v\u0020-\u00ff])*"|[!#$%&'*+.^\w`|~-]+) */gu +const MEDIA_TYPE_TCHAR = 1 +const PARAM_TCHAR = 2 +const QDTEXT = 4 +const UPPER = 8 + +const CHAR_CLASS = new Uint8Array(0x10000) +for (const ch of '!#$%&\'*+-.^_|~0123456789abcdefghijklmnopqrstuvwxyz') { + CHAR_CLASS[ch.charCodeAt(0)] = MEDIA_TYPE_TCHAR | PARAM_TCHAR +} +for (const ch of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') { + CHAR_CLASS[ch.charCodeAt(0)] = MEDIA_TYPE_TCHAR | PARAM_TCHAR | UPPER +} +CHAR_CLASS[0x60] = PARAM_TCHAR // '`' +CHAR_CLASS[0x0b] |= QDTEXT +CHAR_CLASS[0x20] |= QDTEXT +CHAR_CLASS[0x21] |= QDTEXT +for (let i = 0x23; i <= 0x5b; i++) CHAR_CLASS[i] |= QDTEXT +for (let i = 0x5d; i <= 0x7e; i++) CHAR_CLASS[i] |= QDTEXT +for (let i = 0x80; i <= 0xff; i++) CHAR_CLASS[i] |= QDTEXT /** - * RegExp to match quoted-pair in RFC 7230 sec 3.2.6 - * - * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) - * obs-text = %x80-FF + * Whitespace as removed by `String.prototype.trim()`: WhiteSpace and + * LineTerminator code points per ECMA-262. */ -const quotedPairRE = /\\([\v\u0020-\u00ff])/gu +function isTrimWhitespace (code) { + if (code <= 0x20) { + return code === 0x20 || (code >= 0x09 && code <= 0x0d) + } + if (code < 0xa0) { + return false + } + return code === 0xa0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 || + code === 0xfeff +} /** - * RegExp to match type in RFC 7231 sec 3.1.1.1 - * - * media-type = type "/" subtype - * type = token - * subtype = token + * Remove the backslashes of the quoted-pairs in header[start, end). + * The range is known to be a valid quoted-string body. */ -const mediaTypeRE = /^[!#$%&'*+.^\w|~-]+\/[!#$%&'*+.^\w|~-]+$/u +function unescapeQuotedPairs (header, start, end) { + let value = '' + let index = start + while (index < end) { + if (header.charCodeAt(index) === BSLASH) { + value += header.slice(start, index) + start = ++index + } + index++ + } + return value + header.slice(start, end) +} // default ContentType to prevent repeated object creation const defaultContentType = { type: '', parameters: new NullObject() } Object.freeze(defaultContentType.parameters) Object.freeze(defaultContentType) +// sentinel returned by parseHeader when the parameters are malformed +const invalidParameterFormat = { type: '', parameters: defaultContentType.parameters } +Object.freeze(invalidParameterFormat) + /** * Parse media type to object. * - * @param {string|object} header + * Returns `defaultContentType` when the media type is invalid and + * `invalidParameterFormat` when the parameters are malformed. + * + * @param {string} header * @return {Object} - * @public */ +function parseHeader (header) { + const len = header.length + let index = 0 + let code = 0 + let flags = 0 + + // skip leading whitespace + while (index < len) { + code = header.charCodeAt(index) + if (!isTrimWhitespace(code)) break + index++ + } -function parse (header) { - if (typeof header !== 'string') { - throw new TypeError('argument header is required and must be a string') + // media-type = type "/" subtype + const typeStart = index + let upper = 0 + while (index < len) { + code = header.charCodeAt(index) + flags = CHAR_CLASS[code] + if ((flags & MEDIA_TYPE_TCHAR) === 0) break + upper |= flags + index++ } - let index = header.indexOf(';') - const type = index !== -1 - ? header.slice(0, index).trim() - : header.trim() + if (index === typeStart || index === len || code !== SLASH) { + return defaultContentType + } - if (mediaTypeRE.test(type) === false) { - throw new TypeError('invalid media type') + index++ // skip "/" + const subtypeStart = index + while (index < len) { + code = header.charCodeAt(index) + flags = CHAR_CLASS[code] + if ((flags & MEDIA_TYPE_TCHAR) === 0) break + upper |= flags + index++ } + if (index === subtypeStart) { + return defaultContentType + } + + const typeEnd = index + + // skip trailing whitespace + while (index < len) { + code = header.charCodeAt(index) + if (!isTrimWhitespace(code)) break + index++ + } + + if (index !== len && code !== SEMI) { + return defaultContentType + } + + const type = header.slice(typeStart, typeEnd) const result = { - type: type.toLowerCase(), + type: (upper & UPPER) !== 0 ? type.toLowerCase() : type, parameters: new NullObject() } - // parse parameters - if (index === -1) { + if (index === len) { return result } - let key - let match - let value - - paramRE.lastIndex = index + // parse parameters + const parameters = result.parameters + + // *( ";" parameter ) + // parameter = token "=" ( token / quoted-string ) + while (index < len) { + index++ // skip ";" + while (index < len && header.charCodeAt(index) === SP) { + index++ + } - while ((match = paramRE.exec(header))) { - if (match.index !== index) { - throw new TypeError('invalid parameter format') + const keyStart = index + upper = 0 + while (index < len) { + code = header.charCodeAt(index) + flags = CHAR_CLASS[code] + if ((flags & PARAM_TCHAR) === 0) break + upper |= flags + index++ } - index += match[0].length - key = match[1].toLowerCase() - value = match[2] + if (index === keyStart || index === len || code !== EQ) { + return invalidParameterFormat + } - if (value[0] === '"') { - // remove quotes and escapes - value = value - .slice(1, value.length - 1) + const key = header.slice(keyStart, index) + index++ // skip "=" + + let value + if (index < len && header.charCodeAt(index) === DQUOTE) { + // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + index++ + const valueStart = index + let escaped = false + while (index < len) { + code = header.charCodeAt(index) + if (code === DQUOTE) break + if ((CHAR_CLASS[code] & QDTEXT) !== 0) { + index++ + continue + } + if (code !== BSLASH) { + return invalidParameterFormat + } + // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + index++ + if (index === len) { + return invalidParameterFormat + } + code = header.charCodeAt(index) + if (!(code === 0x0b || (code >= 0x20 && code <= 0xff))) { + return invalidParameterFormat + } + escaped = true + index++ + } + + if (index === len) { + return invalidParameterFormat + } + + value = escaped + ? unescapeQuotedPairs(header, valueStart, index) + : header.slice(valueStart, index) + index++ // skip closing DQUOTE + } else { + const valueStart = index + while (index < len && (CHAR_CLASS[header.charCodeAt(index)] & PARAM_TCHAR) !== 0) { + index++ + } + + if (index === valueStart) { + return invalidParameterFormat + } + + value = header.slice(valueStart, index) + } - quotedPairRE.test(value) && (value = value.replace(quotedPairRE, '$1')) + while (index < len && header.charCodeAt(index) === SP) { + index++ } - result.parameters[key] = value - } + if (index !== len && header.charCodeAt(index) !== SEMI) { + return invalidParameterFormat + } - if (index !== header.length) { - throw new TypeError('invalid parameter format') + parameters[(upper & UPPER) !== 0 ? key.toLowerCase() : key] = value } return result } -function safeParse (header) { +/** + * Parse media type to object. + * + * @param {string|object} header + * @return {Object} + * @public + */ + +function parse (header) { if (typeof header !== 'string') { - return defaultContentType + throw new TypeError('argument header is required and must be a string') } - let index = header.indexOf(';') - const type = index !== -1 - ? header.slice(0, index).trim() - : header.trim() - - if (mediaTypeRE.test(type) === false) { - return defaultContentType - } + const result = parseHeader(header) - const result = { - type: type.toLowerCase(), - parameters: new NullObject() + if (result === defaultContentType) { + throw new TypeError('invalid media type') } - // parse parameters - if (index === -1) { - return result + if (result === invalidParameterFormat) { + throw new TypeError('invalid parameter format') } - let key - let match - let value - - paramRE.lastIndex = index - - while ((match = paramRE.exec(header))) { - if (match.index !== index) { - return defaultContentType - } - - index += match[0].length - key = match[1].toLowerCase() - value = match[2] - - if (value[0] === '"') { - // remove quotes and escapes - value = value - .slice(1, value.length - 1) - - quotedPairRE.test(value) && (value = value.replace(quotedPairRE, '$1')) - } + return result +} - result.parameters[key] = value +function safeParse (header) { + if (typeof header !== 'string') { + return defaultContentType } - if (index !== header.length) { + const result = parseHeader(header) + + if (result === invalidParameterFormat) { return defaultContentType } diff --git a/test/index.test.js b/test/index.test.js index ecc314f..89a21bd 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,7 +1,7 @@ 'use strict' const { test } = require('node:test') -const { parse, safeParse } = require('..') +const { parse, safeParse, defaultContentType } = require('..') const invalidTypes = [ ' ', @@ -18,7 +18,7 @@ const invalidTypes = [ ] test('parse', async function (t) { - t.plan(13 + invalidTypes.length) + t.plan(14 + invalidTypes.length) await t.test('should parse basic type', function (t) { t.plan(1) const type = parse('text/html') @@ -37,6 +37,17 @@ test('parse', async function (t) { t.assert.deepStrictEqual(type.type, 'text/html') }) + await t.test('should parse basic type with surrounding unicode whitespace', function (t) { + t.plan(7) + t.assert.deepStrictEqual(parse('\ttext/html\t').type, 'text/html') + t.assert.deepStrictEqual(parse('\r\ntext/html\r\n').type, 'text/html') + t.assert.deepStrictEqual(parse('\u00a0text/html\u00a0').type, 'text/html') + t.assert.deepStrictEqual(parse('\u2003text/html\u2003').type, 'text/html') + t.assert.deepStrictEqual(parse('\u3000text/html\ufeff').type, 'text/html') + t.assert.deepStrictEqual(parse('\u2028text/html\u2029').type, 'text/html') + t.assert.deepStrictEqual(parse('text/html\n; charset=utf-8').parameters.charset, 'utf-8') + }) + await t.test('should parse parameters', function (t) { t.plan(2) const type = parse('text/html; charset=utf-8; foo=bar') @@ -108,10 +119,18 @@ test('parse', async function (t) { }) await t.test('should throw on invalid parameter format', function (t) { - t.plan(3) + t.plan(11) t.assert.throws(parse.bind(null, 'text/plain; foo="bar'), new TypeError('invalid parameter format')) t.assert.throws(parse.bind(null, 'text/plain; profile=http://localhost; foo=bar'), new TypeError('invalid parameter format')) t.assert.throws(parse.bind(null, 'text/plain; profile=http://localhost'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; =bar'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo ="bar"'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo='), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo= bar'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo="ba\\\tr"'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo="bar\\'), new TypeError('invalid parameter format')) + t.assert.throws(parse.bind(null, 'text/plain; foo="b\tar"'), new TypeError('invalid parameter format')) }) await t.test('should require argument', function (t) { @@ -128,7 +147,7 @@ test('parse', async function (t) { }) test('safeParse', async function (t) { - t.plan(13 + invalidTypes.length) + t.plan(14 + invalidTypes.length) await t.test('should safeParse basic type', function (t) { t.plan(1) const type = safeParse('text/html') @@ -147,6 +166,17 @@ test('safeParse', async function (t) { t.assert.deepStrictEqual(type.type, 'text/html') }) + await t.test('should safeParse basic type with surrounding unicode whitespace', function (t) { + t.plan(7) + t.assert.deepStrictEqual(safeParse('\ttext/html\t').type, 'text/html') + t.assert.deepStrictEqual(safeParse('\r\ntext/html\r\n').type, 'text/html') + t.assert.deepStrictEqual(safeParse('\u00a0text/html\u00a0').type, 'text/html') + t.assert.deepStrictEqual(safeParse('\u2003text/html\u2003').type, 'text/html') + t.assert.deepStrictEqual(safeParse('\u3000text/html\ufeff').type, 'text/html') + t.assert.deepStrictEqual(safeParse('\u2028text/html\u2029').type, 'text/html') + t.assert.deepStrictEqual(safeParse('text/html\n; charset=utf-8').parameters.charset, 'utf-8') + }) + await t.test('should safeParse parameters', function (t) { t.plan(2) const type = safeParse('text/html; charset=utf-8; foo=bar') @@ -219,7 +249,7 @@ test('safeParse', async function (t) { }) await t.test('should return dummyContentType on invalid parameter format', function (t) { - t.plan(6) + t.plan(11) t.assert.deepStrictEqual(safeParse('text/plain; foo="bar').type, '') t.assert.deepStrictEqual(Object.keys(safeParse('text/plain; foo="bar').parameters).length, 0) @@ -228,6 +258,12 @@ test('safeParse', async function (t) { t.assert.deepStrictEqual(safeParse('text/plain; profile=http://localhost').type, '') t.assert.deepStrictEqual(Object.keys(safeParse('text/plain; profile=http://localhost').parameters).length, 0) + + t.assert.strictEqual(safeParse('text/plain; foo'), defaultContentType) + t.assert.strictEqual(safeParse('text/plain; =bar'), defaultContentType) + t.assert.strictEqual(safeParse('text/plain; foo='), defaultContentType) + t.assert.strictEqual(safeParse('text/plain; foo="ba\\\tr"'), defaultContentType) + t.assert.strictEqual(safeParse('text/plain; foo="bar\\'), defaultContentType) }) await t.test('should return dummyContentType on missing argument', function (t) {