From 5d63bb3aa5b93d0e8075428a2c4bb65d661f78db Mon Sep 17 00:00:00 2001 From: tsctx <91457664+tsctx@users.noreply.github.com> Date: Sun, 10 Dec 2023 00:04:56 +0900 Subject: [PATCH] perf: reduce `String#toLowerCase` call (#2516) --- lib/client.js | 13 +++--- lib/core/util.js | 10 ++++- lib/fetch/body.js | 2 +- lib/fetch/headers.js | 99 ++++++++++++++++++++++++++++++++++++++----- lib/fetch/index.js | 86 ++++++++++++++++++------------------- lib/fetch/response.js | 6 +-- lib/fetch/util.js | 10 ++--- 7 files changed, 158 insertions(+), 68 deletions(-) diff --git a/lib/client.js b/lib/client.js index 2fec533faf1..b83863e296b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -765,11 +765,14 @@ class Parser { } const key = this.headers[len - 2] - if (key.length === 10 && key.toString().toLowerCase() === 'keep-alive') { - this.keepAlive += buf.toString() - } else if (key.length === 10 && key.toString().toLowerCase() === 'connection') { - this.connection += buf.toString() - } else if (key.length === 14 && key.toString().toLowerCase() === 'content-length') { + if (key.length === 10) { + const headerName = util.bufferToLowerCasedHeaderName(key) + if (headerName === 'keep-alive') { + this.keepAlive += buf.toString() + } else if (headerName === 'connection') { + this.connection += buf.toString() + } + } else if (key.length === 14 && util.bufferToLowerCasedHeaderName(key) === 'content-length') { this.contentLength += buf.toString() } diff --git a/lib/core/util.js b/lib/core/util.js index 49d1c9938ed..6c2e1e55d21 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -226,7 +226,14 @@ function parseKeepAliveTimeout (val) { function headerNameToString (value) { return typeof value === 'string' ? headerNameLowerCasedRecord[value] ?? value.toLowerCase() - : tree.lookup(value) ?? value.toString().toLowerCase() + : tree.lookup(value) ?? value.toString('latin1').toLowerCase() +} + +/** + * @param {Buffer} value + */ +function bufferToLowerCasedHeaderName (value) { + return tree.lookup(value) ?? value.toString('latin1').toLowerCase() } /** @@ -523,6 +530,7 @@ module.exports = { isAsyncIterable, isDestroyed, headerNameToString, + bufferToLowerCasedHeaderName, parseRawHeaders, parseHeaders, parseKeepAliveTimeout, diff --git a/lib/fetch/body.js b/lib/fetch/body.js index 2a8b38f7415..6202887289a 100644 --- a/lib/fetch/body.js +++ b/lib/fetch/body.js @@ -374,7 +374,7 @@ function bodyMixinMethods (instance) { // If mimeType’s essence is "multipart/form-data", then: if (/multipart\/form-data/.test(contentType)) { const headers = {} - for (const [key, value] of this.headers) headers[key.toLowerCase()] = value + for (const [key, value] of this.headers) headers[key] = value const responseFormData = new FormData() diff --git a/lib/fetch/headers.js b/lib/fetch/headers.js index 2f1c0be5a47..883d7637cbf 100644 --- a/lib/fetch/headers.js +++ b/lib/fetch/headers.js @@ -135,13 +135,22 @@ class HeadersList { } } - // https://fetch.spec.whatwg.org/#header-list-contains + /** + * @see https://fetch.spec.whatwg.org/#header-list-contains + * @param {string} name + */ contains (name) { // A header list list contains a header name name if list // contains a header whose name is a byte-case-insensitive // match for name. - name = name.toLowerCase() + return this[kHeadersMap].has(name.toLowerCase()) + } + + /** + * @param {string} name + */ + lowerCaseContains (name) { return this[kHeadersMap].has(name) } @@ -151,7 +160,11 @@ class HeadersList { this.cookies = null } - // https://fetch.spec.whatwg.org/#concept-header-list-append + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-append + * @param {string} name + * @param {string} value + */ append (name, value) { this[kHeadersSortedMap] = null @@ -172,12 +185,42 @@ class HeadersList { } if (lowercaseName === 'set-cookie') { - this.cookies ??= [] - this.cookies.push(value) + (this.cookies ??= []).push(value) + } + } + + /** + * @param {string} name + * @param {string} value + */ + lowerCaseAppend (name, value) { + this[kHeadersSortedMap] = null + + // 1. If list contains name, then set name to the first such + // header’s name. + const exists = this[kHeadersMap].get(name) + + // 2. Append (name, value) to list. + if (exists) { + const delimiter = name === 'cookie' ? '; ' : ', ' + this[kHeadersMap].set(name, { + name: exists.name, + value: `${exists.value}${delimiter}${value}` + }) + } else { + this[kHeadersMap].set(name, { name, value }) + } + + if (name === 'set-cookie') { + (this.cookies ??= []).push(value) } } - // https://fetch.spec.whatwg.org/#concept-header-list-set + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-set + * @param {string} name + * @param {string} value + */ set (name, value) { this[kHeadersSortedMap] = null const lowercaseName = name.toLowerCase() @@ -193,11 +236,35 @@ class HeadersList { this[kHeadersMap].set(lowercaseName, { name, value }) } - // https://fetch.spec.whatwg.org/#concept-header-list-delete + /** + * @param {string} name + * @param {string} value + */ + lowerCaseSet (name, value) { + if (name === 'set-cookie') { + this.cookies = [value] + } + + // 1. If list contains name, then set the value of + // the first such header to value and remove the + // others. + // 2. Otherwise, append header (name, value) to list. + this[kHeadersMap].set(name, { name, value }) + } + + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-delete + * @param {string} name + */ delete (name) { - this[kHeadersSortedMap] = null + return this.lowerCaseDelete(name.toLowerCase()) + } - name = name.toLowerCase() + /** + * @param {string} name + */ + lowerCaseDelete (name) { + this[kHeadersSortedMap] = null if (name === 'set-cookie') { this.cookies = null @@ -206,7 +273,11 @@ class HeadersList { this[kHeadersMap].delete(name) } - // https://fetch.spec.whatwg.org/#concept-header-list-get + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-get + * @param {string} name + * @returns {string | null} + */ get (name) { const value = this[kHeadersMap].get(name.toLowerCase()) @@ -217,6 +288,14 @@ class HeadersList { return value === undefined ? null : value.value } + /** + * @param {string} name + * @returns {string | null} + */ + lowerCaseGet (name) { + return this[kHeadersMap].get(name)?.value ?? null + } + * [Symbol.iterator] () { // use the lowercased name for (const [name, { value }] of this[kHeadersMap]) { diff --git a/lib/fetch/index.js b/lib/fetch/index.js index b3b6e71eb79..0e5fc35a13d 100644 --- a/lib/fetch/index.js +++ b/lib/fetch/index.js @@ -57,7 +57,7 @@ const { const { kHeadersList, kConstruct } = require('../core/symbols') const EE = require('events') const { Readable, pipeline } = require('stream') -const { addAbortListener, isErrored, isReadable, nodeMajor, nodeMinor } = require('../core/util') +const { addAbortListener, isErrored, isReadable, nodeMajor, nodeMinor, bufferToLowerCasedHeaderName } = require('../core/util') const { dataURLProcessor, serializeAMimeType, parseMIMEType } = require('./dataURL') const { getGlobalDispatcher } = require('../global') const { webidl } = require('./webidl') @@ -475,7 +475,7 @@ function fetching ({ } // 12. If request’s header list does not contain `Accept`, then: - if (!request.headersList.contains('accept')) { + if (!request.headersList.lowerCaseContains('accept')) { // 1. Let value be `*/*`. const value = '*/*' @@ -492,14 +492,14 @@ function fetching ({ // TODO // 3. Append `Accept`/value to request’s header list. - request.headersList.append('accept', value) + request.headersList.lowerCaseAppend('accept', value) } // 13. If request’s header list does not contain `Accept-Language`, then // user agents should append `Accept-Language`/an appropriate value to // request’s header list. - if (!request.headersList.contains('accept-language')) { - request.headersList.append('accept-language', '*') + if (!request.headersList.lowerCaseContains('accept-language')) { + request.headersList.lowerCaseAppend('accept-language', '*') } // 14. If request’s priority is null, then use request’s initiator and @@ -718,7 +718,7 @@ async function mainFetch (fetchParams, recursive = false) { response.type === 'opaque' && internalResponse.status === 206 && internalResponse.rangeRequested && - !request.headers.contains('range') + !request.headers.lowerCaseContains('range') ) { response = internalResponse = makeNetworkError() } @@ -840,7 +840,7 @@ function schemeFetch (fetchParams) { // 8. If request’s header list does not contain `Range`: // 9. Otherwise: - if (!request.headersList.contains('range')) { + if (!request.headersList.lowerCaseContains('range')) { // 1. Let bodyWithType be the result of safely extracting blob. // Note: in the FileAPI a blob "object" is a Blob *or* a MediaSource. // In node, this can only ever be a Blob. Therefore we can safely @@ -854,14 +854,14 @@ function schemeFetch (fetchParams) { response.body = bodyWithType[0] // 4. Set response’s header list to « (`Content-Length`, serializedFullLength), (`Content-Type`, type) ». - response.headersList.set('content-length', serializedFullLength) - response.headersList.set('content-type', type) + response.headersList.lowerCaseSet('content-length', serializedFullLength) + response.headersList.lowerCaseSet('content-type', type) } else { // 1. Set response’s range-requested flag. response.rangeRequested = true // 2. Let rangeHeader be the result of getting `Range` from request’s header list. - const rangeHeader = request.headersList.get('range') + const rangeHeader = request.headersList.lowerCaseGet('range') // 3. Let rangeValue be the result of parsing a single range header value given rangeHeader and true. const rangeValue = simpleRangeHeaderValue(rangeHeader, true) @@ -921,9 +921,9 @@ function schemeFetch (fetchParams) { // 15. Set response’s header list to « (`Content-Length`, serializedSlicedLength), // (`Content-Type`, type), (`Content-Range`, contentRange) ». - response.headersList.set('content-length', serializedSlicedLength) - response.headersList.set('content-type', type) - response.headersList.set('content-range', contentRange) + response.headersList.lowerCaseSet('content-length', serializedSlicedLength) + response.headersList.lowerCaseSet('content-type', type) + response.headersList.lowerCaseSet('content-range', contentRange) } // 10. Return response. @@ -1040,7 +1040,7 @@ function fetchFinale (fetchParams, response) { responseStatus = response.status // 2. Let mimeType be the result of extracting a MIME type from response’s header list. - const mimeType = parseMIMEType(response.headersList.get('content-type')) // TODO: fix + const mimeType = parseMIMEType(response.headersList.lowerCaseGet('content-type')) // TODO: fix // 3. If mimeType is not failure, then set bodyInfo’s content type to the result of minimizing a supported MIME type given mimeType. if (mimeType !== 'failure') { @@ -1336,11 +1336,11 @@ function httpRedirectFetch (fetchParams, response) { // delete headerName from request’s header list. if (!sameOrigin(requestCurrentURL(request), locationURL)) { // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name - request.headersList.delete('authorization') + request.headersList.lowerCaseDelete('authorization') // "Cookie" and "Host" are forbidden request-headers, which undici doesn't implement. - request.headersList.delete('cookie') - request.headersList.delete('host') + request.headersList.lowerCaseDelete('cookie') + request.headersList.lowerCaseDelete('host') } // 14. If request’s body is non-null, then set request’s body to the first return @@ -1456,7 +1456,7 @@ async function httpNetworkOrCacheFetch ( // `Content-Length`/contentLengthHeaderValue to httpRequest’s header // list. if (contentLengthHeaderValue != null) { - httpRequest.headersList.append('content-length', contentLengthHeaderValue) + httpRequest.headersList.lowerCaseAppend('content-length', contentLengthHeaderValue) } // 9. If contentLengthHeaderValue is non-null, then append (`Content-Length`, @@ -1472,7 +1472,7 @@ async function httpNetworkOrCacheFetch ( // `Referer`/httpRequest’s referrer, serialized and isomorphic encoded, // to httpRequest’s header list. if (httpRequest.referrer instanceof URL) { - httpRequest.headersList.append('referer', isomorphicEncode(httpRequest.referrer.href)) + httpRequest.headersList.lowerCaseAppend('referer', isomorphicEncode(httpRequest.referrer.href)) } // 12. Append a request `Origin` header for httpRequest. @@ -1484,8 +1484,8 @@ async function httpNetworkOrCacheFetch ( // 14. If httpRequest’s header list does not contain `User-Agent`, then // user agents should append `User-Agent`/default `User-Agent` value to // httpRequest’s header list. - if (!httpRequest.headersList.contains('user-agent')) { - httpRequest.headersList.append('user-agent', typeof esbuildDetection === 'undefined' ? 'undici' : 'node') + if (!httpRequest.headersList.lowerCaseContains('user-agent')) { + httpRequest.headersList.lowerCaseAppend('user-agent', typeof esbuildDetection === 'undefined' ? 'undici' : 'node') } // 15. If httpRequest’s cache mode is "default" and httpRequest’s header @@ -1494,11 +1494,11 @@ async function httpNetworkOrCacheFetch ( // httpRequest’s cache mode to "no-store". if ( httpRequest.cache === 'default' && - (httpRequest.headersList.contains('if-modified-since') || - httpRequest.headersList.contains('if-none-match') || - httpRequest.headersList.contains('if-unmodified-since') || - httpRequest.headersList.contains('if-match') || - httpRequest.headersList.contains('if-range')) + (httpRequest.headersList.lowerCaseContains('if-modified-since') || + httpRequest.headersList.lowerCaseContains('if-none-match') || + httpRequest.headersList.lowerCaseContains('if-unmodified-since') || + httpRequest.headersList.lowerCaseContains('if-match') || + httpRequest.headersList.lowerCaseContains('if-range')) ) { httpRequest.cache = 'no-store' } @@ -1510,44 +1510,44 @@ async function httpNetworkOrCacheFetch ( if ( httpRequest.cache === 'no-cache' && !httpRequest.preventNoCacheCacheControlHeaderModification && - !httpRequest.headersList.contains('cache-control') + !httpRequest.headersList.lowerCaseContains('cache-control') ) { - httpRequest.headersList.append('cache-control', 'max-age=0') + httpRequest.headersList.lowerCaseAppend('cache-control', 'max-age=0') } // 17. If httpRequest’s cache mode is "no-store" or "reload", then: if (httpRequest.cache === 'no-store' || httpRequest.cache === 'reload') { // 1. If httpRequest’s header list does not contain `Pragma`, then append // `Pragma`/`no-cache` to httpRequest’s header list. - if (!httpRequest.headersList.contains('pragma')) { - httpRequest.headersList.append('pragma', 'no-cache') + if (!httpRequest.headersList.lowerCaseContains('pragma')) { + httpRequest.headersList.lowerCaseAppend('pragma', 'no-cache') } // 2. If httpRequest’s header list does not contain `Cache-Control`, // then append `Cache-Control`/`no-cache` to httpRequest’s header list. - if (!httpRequest.headersList.contains('cache-control')) { - httpRequest.headersList.append('cache-control', 'no-cache') + if (!httpRequest.headersList.lowerCaseContains('cache-control')) { + httpRequest.headersList.lowerCaseAppend('cache-control', 'no-cache') } } // 18. If httpRequest’s header list contains `Range`, then append // `Accept-Encoding`/`identity` to httpRequest’s header list. - if (httpRequest.headersList.contains('range')) { - httpRequest.headersList.append('accept-encoding', 'identity') + if (httpRequest.headersList.lowerCaseContains('range')) { + httpRequest.headersList.lowerCaseAppend('accept-encoding', 'identity') } // 19. Modify httpRequest’s header list per HTTP. Do not append a given // header if httpRequest’s header list contains that header’s name. // TODO: https://github.com/whatwg/fetch/issues/1285#issuecomment-896560129 - if (!httpRequest.headersList.contains('accept-encoding')) { + if (!httpRequest.headersList.lowerCaseContains('accept-encoding')) { if (urlHasHttpsScheme(requestCurrentURL(httpRequest))) { - httpRequest.headersList.append('accept-encoding', 'br, gzip, deflate') + httpRequest.headersList.lowerCaseAppend('accept-encoding', 'br, gzip, deflate') } else { - httpRequest.headersList.append('accept-encoding', 'gzip, deflate') + httpRequest.headersList.lowerCaseAppend('accept-encoding', 'gzip, deflate') } } - httpRequest.headersList.delete('host') + httpRequest.headersList.lowerCaseDelete('host') // 20. If includeCredentials is true, then: if (includeCredentials) { @@ -1630,7 +1630,7 @@ async function httpNetworkOrCacheFetch ( // 12. If httpRequest’s header list contains `Range`, then set response’s // range-requested flag. - if (httpRequest.headersList.contains('range')) { + if (httpRequest.headersList.lowerCaseContains('range')) { response.rangeRequested = true } @@ -2121,15 +2121,15 @@ async function httpNetworkFetch ( // We distinguish between them and iterate accordingly if (Array.isArray(rawHeaders)) { for (let i = 0; i < rawHeaders.length; i += 2) { - headersList.append(rawHeaders[i].toString('latin1'), rawHeaders[i + 1].toString('latin1')) + headersList.lowerCaseAppend(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString('latin1')) } - const contentEncoding = headersList.get('content-encoding') + const contentEncoding = headersList.lowerCaseGet('content-encoding') if (contentEncoding) { // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 // "All content-coding values are case-insensitive..." codings = contentEncoding.toLowerCase().split(',').map((x) => x.trim()) } - location = headersList.get('location') + location = headersList.lowerCaseGet('location') } else { const keys = Object.keys(rawHeaders) for (let i = 0; i < keys.length; ++i) { @@ -2243,7 +2243,7 @@ async function httpNetworkFetch ( const headersList = new HeadersList() for (let i = 0; i < rawHeaders.length; i += 2) { - headersList.append(rawHeaders[i].toString('latin1'), rawHeaders[i + 1].toString('latin1')) + headersList.lowerCaseAppend(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString('latin1')) } resolve({ diff --git a/lib/fetch/response.js b/lib/fetch/response.js index b0b0a2ce543..54ef3595416 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -126,7 +126,7 @@ class Response { const value = isomorphicEncode(URLSerializer(parsedURL)) // 7. Append `Location`/value to responseObject’s response’s header list. - responseObject[kState].headersList.append('location', value) + responseObject[kState].headersList.lowerCaseAppend('location', value) // 8. Return responseObject. return responseObject @@ -496,8 +496,8 @@ function initializeResponse (response, init, body) { // 3. If body's type is non-null and response's header list does not contain // `Content-Type`, then append (`Content-Type`, body's type) to response's header list. - if (body.type != null && !response[kState].headersList.contains('Content-Type')) { - response[kState].headersList.append('content-type', body.type) + if (body.type != null && !response[kState].headersList.lowerCaseContains('content-type')) { + response[kState].headersList.lowerCaseAppend('content-type', body.type) } } } diff --git a/lib/fetch/util.js b/lib/fetch/util.js index 9843f97444b..13c4b468cc6 100644 --- a/lib/fetch/util.js +++ b/lib/fetch/util.js @@ -35,7 +35,7 @@ function responseLocationURL (response, requestFragment) { // 2. Let location be the result of extracting header list values given // `Location` and response’s header list. - let location = response.headersList.get('location') + let location = response.headersList.lowerCaseGet('location') // 3. If location is a header value, then set location to the result of // parsing location with response’s URL. @@ -153,7 +153,7 @@ function setRequestReferrerPolicyOnRedirect (request, actualResponse) { // 2. Let policy be the empty string. // 3. For each token in policy-tokens, if token is a referrer policy and token is not the empty string, then set policy to token. // 4. Return policy. - const policyHeader = (headersList.get('referrer-policy') ?? '').split(',') + const policyHeader = (headersList.lowerCaseGet('referrer-policy') ?? '').split(',') // Note: As the referrer-policy can contain multiple policies // separated by comma, we need to loop through all of them @@ -212,7 +212,7 @@ function appendFetchMetadata (httpRequest) { header = httpRequest.mode // 4. Set a structured field value `Sec-Fetch-Mode`/header in r’s header list. - httpRequest.headersList.set('sec-fetch-mode', header) + httpRequest.headersList.lowerCaseSet('sec-fetch-mode', header) // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-site-header // TODO @@ -229,7 +229,7 @@ function appendRequestOriginHeader (request) { // 2. If request’s response tainting is "cors" or request’s mode is "websocket", then append (`Origin`, serializedOrigin) to request’s header list. if (request.responseTainting === 'cors' || request.mode === 'websocket') { if (serializedOrigin) { - request.headersList.append('origin', serializedOrigin) + request.headersList.lowerCaseAppend('origin', serializedOrigin) } // 3. Otherwise, if request’s method is neither `GET` nor `HEAD`, then: @@ -260,7 +260,7 @@ function appendRequestOriginHeader (request) { if (serializedOrigin) { // 2. Append (`Origin`, serializedOrigin) to request’s header list. - request.headersList.append('origin', serializedOrigin) + request.headersList.lowerCaseAppend('origin', serializedOrigin) } } }