undici
Version:
An HTTP/1.1 client, written from scratch for Node.js
717 lines (618 loc) • 21.4 kB
JavaScript
const {
safeHTTPMethods,
pathHasQueryOrFragment,
hasSafeIterator,
isValidHTTPToken
} = require('../core/util')
const { serializePathWithQuery } = require('../core/util')
const MAX_DELTA_SECONDS = 2147483647
const RESTRICTIVE_DIRECTIVE_NAMES = ['no-store', 'private', 'no-cache']
const kInvalidCacheControlDirectives = Symbol('invalid cache-control directives')
function trimOWS (value) {
return value.replace(/^[\t ]+|[\t ]+$/g, '')
}
function arrayIncludes (array, value) {
for (let i = 0; i < array.length; i++) {
if (array[i] === value) {
return true
}
}
return false
}
function trimOWSStart (value) {
return value.replace(/^[\t ]+/, '')
}
function trimOWSEnd (value) {
return value.replace(/[\t ]+$/, '')
}
function findUnescapedQuote (value, start) {
let escaped = false
for (let i = start; i < value.length; i++) {
if (escaped) {
escaped = false
} else if (value[i] === '\\') {
escaped = true
} else if (value[i] === '"') {
return i
}
}
return -1
}
function splitCacheControlHeaderValue (value) {
const directives = []
let start = 0
let quoteStart = -1
let inQuote = false
let escaped = false
for (let i = 0; i < value.length; i++) {
if (inQuote) {
if (escaped) {
escaped = false
} else if (value[i] === '\\') {
escaped = true
} else if (value[i] === '"') {
inQuote = false
quoteStart = -1
}
} else if (value[i] === '"') {
inQuote = true
quoteStart = i
} else if (value[i] === ',') {
directives.push({ value: value.substring(start, i), fromMalformedQuote: false })
start = i + 1
}
}
if (!inQuote) {
directives.push({ value: value.substring(start), fromMalformedQuote: false })
return directives
}
const tail = value.substring(start)
const quoteOffset = quoteStart - start
let tailStart = 0
for (let i = 0; i < tail.length; i++) {
if (tail[i] === ',') {
directives.push({
value: tail.substring(tailStart, i),
fromMalformedQuote: tailStart > quoteOffset
})
tailStart = i + 1
}
}
directives.push({
value: tail.substring(tailStart),
fromMalformedQuote: tailStart > quoteOffset
})
return directives
}
function markInvalidCacheControlDirective (directives, key) {
let invalidDirectives = directives[kInvalidCacheControlDirectives]
if (invalidDirectives === undefined) {
invalidDirectives = new Set()
Object.defineProperty(directives, kInvalidCacheControlDirectives, {
value: invalidDirectives
})
}
invalidDirectives.add(key)
}
function hasInvalidCacheControlDirective (directives, key) {
return directives[kInvalidCacheControlDirectives]?.has(key) === true
}
function getMalformedRestrictiveDirectiveName (key) {
for (const directiveName of RESTRICTIVE_DIRECTIVE_NAMES) {
if (
key.startsWith(directiveName) &&
key.length > directiveName.length &&
!isValidHTTPToken(key[directiveName.length])
) {
return directiveName
}
}
let tokenOnlyKey = ''
let hasInvalidTokenChar = false
for (let i = 0; i < key.length; i++) {
if (isValidHTTPToken(key[i])) {
tokenOnlyKey += key[i]
} else {
hasInvalidTokenChar = true
}
}
if (hasInvalidTokenChar && arrayIncludes(RESTRICTIVE_DIRECTIVE_NAMES, tokenOnlyKey)) {
return tokenOnlyKey
}
}
/**
* @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
*/
function makeCacheKey (opts) {
const origin = opts.origin ? opts.origin.toString() : ''
let fullPath = opts.path || '/'
if (opts.query && !pathHasQueryOrFragment(fullPath)) {
fullPath = serializePathWithQuery(fullPath, opts.query)
}
return {
origin,
method: opts.method,
path: fullPath,
headers: opts.headers
}
}
function appendHeader (headers, key, val) {
const headerName = key.toLowerCase()
const current = headers[headerName]
const values = Array.isArray(val) ? val : [val]
if (current === undefined) {
headers[headerName] = Array.isArray(val) ? val.slice() : val
} else if (Array.isArray(current)) {
current.push(...values)
} else {
headers[headerName] = [current, ...values]
}
}
/**
* @param {Record<string, string[] | string>}
* @returns {Record<string, string[] | string>}
*/
function normalizeHeaders (opts) {
let headers
if (opts.headers == null) {
headers = {}
} else if (typeof opts.headers === 'object') {
headers = {}
if (hasSafeIterator(opts.headers)) {
if (Array.isArray(opts.headers)) {
// Array format: could be flat alternating [k, v, k, v, ...]
// or array-of-pairs [[k, v], ...]
const first = opts.headers[0]
if (Array.isArray(first)) {
for (const x of opts.headers) {
if (!Array.isArray(x)) {
throw new Error('opts.headers is not a valid header map')
}
const [key, val] = x
if (typeof key !== 'string' || typeof val !== 'string') {
throw new Error('opts.headers is not a valid header map')
}
appendHeader(headers, key, val)
}
} else {
// Flat alternating array [k, v, k, v, ...]
const len = opts.headers.length
if (len % 2 !== 0) {
throw new Error('opts.headers is not a valid header map')
}
for (let i = 0; i < len; i += 2) {
const key = opts.headers[i]
const val = opts.headers[i + 1]
if (typeof key !== 'string' || (typeof val !== 'string' && !Array.isArray(val))) {
throw new Error('opts.headers is not a valid header map')
}
if (typeof val === 'string') {
appendHeader(headers, key, val)
} else {
const mapped = []
for (let j = 0; j < val.length; j++) {
const v = val[j]
mapped.push(typeof v === 'string' ? v : v.toString('latin1'))
}
appendHeader(headers, key, mapped)
}
}
}
} else {
// Non-array iterable (e.g. Map) — use original iteration logic
for (const x of opts.headers) {
if (!Array.isArray(x)) {
throw new Error('opts.headers is not a valid header map')
}
const [key, val] = x
if (typeof key !== 'string' || typeof val !== 'string') {
throw new Error('opts.headers is not a valid header map')
}
appendHeader(headers, key, val)
}
}
} else {
for (const key of Object.keys(opts.headers)) {
appendHeader(headers, key, opts.headers[key])
}
}
} else {
throw new Error('opts.headers is not an object')
}
return headers
}
/**
* @param {any} key
*/
function assertCacheKey (key) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}
for (const property of ['origin', 'method', 'path']) {
if (typeof key[property] !== 'string') {
throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`)
}
}
if (key.headers !== undefined && typeof key.headers !== 'object') {
throw new TypeError(`expected headers to be object, got ${typeof key}`)
}
}
/**
* @param {any} value
*/
function assertCacheValue (value) {
if (typeof value !== 'object') {
throw new TypeError(`expected value to be object, got ${typeof value}`)
}
for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) {
if (typeof value[property] !== 'number') {
throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`)
}
}
if (typeof value.statusMessage !== 'string') {
throw new TypeError(`expected value.statusMessage to be string, got ${typeof value.statusMessage}`)
}
if (value.headers != null && typeof value.headers !== 'object') {
throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`)
}
if (value.vary !== undefined && typeof value.vary !== 'object') {
throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`)
}
if (value.etag !== undefined && typeof value.etag !== 'string') {
throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`)
}
}
/**
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control
* @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml
* @param {string | string[]} header
* @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
*/
function parseCacheControlHeader (header) {
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives}
*/
const output = {}
const invalidNumericDirectives = new Set()
const invalidNoArgumentDirectives = new Set()
const directives = splitCacheControlHeaderValue(Array.isArray(header) ? header.join(',') : header)
for (let i = 0; i < directives.length; i++) {
const directiveRecord = directives[i]
const directive = directiveRecord.value.toLowerCase()
const fromMalformedQuote = directiveRecord.fromMalformedQuote
const keyValueDelimiter = directive.indexOf('=')
let key
let value
let keyHasTrailingWhitespace = false
let valueHasLeadingWhitespace = false
if (keyValueDelimiter !== -1) {
const rawKey = directive.substring(0, keyValueDelimiter)
const rawValue = directive.substring(keyValueDelimiter + 1)
keyHasTrailingWhitespace = trimOWSEnd(rawKey) !== rawKey
valueHasLeadingWhitespace = trimOWSStart(rawValue) !== rawValue
key = trimOWS(rawKey)
value = trimOWSStart(rawValue)
} else {
key = trimOWS(directive)
}
const malformedRestrictiveDirectiveName = getMalformedRestrictiveDirectiveName(key)
if (malformedRestrictiveDirectiveName !== undefined) {
output[malformedRestrictiveDirectiveName] = true
continue
}
switch (key) {
case 'min-fresh':
case 'max-stale':
case 'max-age':
case 's-maxage':
case 'stale-while-revalidate':
case 'stale-if-error': {
if (fromMalformedQuote || invalidNumericDirectives.has(key)) {
continue
}
if (value === undefined || keyHasTrailingWhitespace || valueHasLeadingWhitespace) {
delete output[key]
invalidNumericDirectives.add(key)
markInvalidCacheControlDirective(output, key)
continue
}
if (
value.length >= 2 &&
value[0] === '"' &&
value[value.length - 1] === '"'
) {
value = value.substring(1, value.length - 1)
}
if (!/^[0-9]+$/.test(value)) {
delete output[key]
invalidNumericDirectives.add(key)
markInvalidCacheControlDirective(output, key)
continue
}
const parsedValue = Math.min(parseInt(value, 10), MAX_DELTA_SECONDS)
if (key === 'min-fresh') {
if (!(key in output) || output[key] < parsedValue) {
output[key] = parsedValue
}
} else if (!(key in output) || output[key] > parsedValue) {
output[key] = parsedValue
}
break
}
case 'private':
case 'no-cache': {
if (fromMalformedQuote) {
output[key] = true
break
}
if (value !== undefined && value.length === 0) {
output[key] = true
break
}
if (value) {
// The private and no-cache directives can be unqualified (aka just
// `private` or `no-cache`) or qualified (w/ a value). When they're
// qualified, it's a list of headers like `no-cache=header1`,
// `no-cache="header1"`, or `no-cache="header1, header2"`
// If we're given multiple headers, the comma messes us up since
// we split the full header by commas. So, let's loop through the
// remaining parts in front of us until we find one that contains a
// closing quote. We can then skip the consumed quoted-list fragments and
// continue parsing like normal.
// https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
if (value[0] === '"') {
// Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`.
value = trimOWSEnd(value)
let fieldList = ''
let lastQuotedPart = i
let foundEndingQuote = false
const closingQuote = findUnescapedQuote(value, 1)
if (closingQuote !== -1) {
fieldList = value.substring(1, closingQuote)
foundEndingQuote = true
} else {
// Something like `no-cache="some-header, another-header"`
// This can still be something invalid, e.g. `no-cache="some-header, ...`
const fieldListParts = [value.substring(1)]
for (let j = i + 1; j < directives.length; j++) {
const nextPart = trimOWS(directives[j].value)
const closingQuote = findUnescapedQuote(nextPart, 0)
lastQuotedPart = j
if (closingQuote !== -1) {
fieldListParts.push(nextPart.substring(0, closingQuote))
foundEndingQuote = true
break
}
fieldListParts.push(nextPart)
}
fieldList = fieldListParts.join(',')
}
if (!foundEndingQuote) {
output[key] = true
break
}
i = lastQuotedPart
const headers = fieldList.split(',')
let validFieldNames = true
for (let j = 0; j < headers.length; j++) {
headers[j] = trimOWS(headers[j])
if (!isValidHTTPToken(headers[j])) {
validFieldNames = false
}
}
if (!validFieldNames) {
output[key] = true
} else if (output[key] !== true) {
if (key in output) {
output[key] = output[key].concat(headers)
} else {
output[key] = headers
}
}
} else {
// Something like `no-cache=some-header`
const fieldName = trimOWS(value)
if (!isValidHTTPToken(fieldName)) {
output[key] = true
} else if (output[key] !== true) {
if (key in output) {
output[key] = output[key].concat(fieldName)
} else {
output[key] = [fieldName]
}
}
}
break
}
}
// eslint-disable-next-line no-fallthrough
case 'public':
case 'must-revalidate':
case 'proxy-revalidate':
case 'immutable':
case 'no-transform':
case 'must-understand':
case 'only-if-cached':
if (fromMalformedQuote || invalidNoArgumentDirectives.has(key)) {
continue
}
if (value !== undefined) {
// These are qualified (something like `public=...`) when they aren't
// allowed to be, skip all instances of the malformed directive.
delete output[key]
invalidNoArgumentDirectives.add(key)
continue
}
output[key] = true
break
case 'no-store':
output[key] = true
break
default:
// Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1
continue
}
}
return output
}
/**
* @param {string | string[]} varyHeader Vary header from the server
* @returns {string[]}
*/
function splitVaryHeader (varyHeader) {
const values = Array.isArray(varyHeader) ? varyHeader : [varyHeader]
const output = []
for (let i = 0; i < values.length; i++) {
const parts = values[i].split(',')
for (let j = 0; j < parts.length; j++) {
output.push(parts[j])
}
}
return output
}
/**
* @param {string | string[]} varyHeader Vary header from the server
* @returns {boolean}
*/
function hasVaryStar (varyHeader) {
const values = splitVaryHeader(varyHeader)
for (let i = 0; i < values.length; i++) {
if (trimOWS(values[i]).indexOf('*') !== -1) {
return true
}
}
return false
}
/**
* @param {string | string[]} varyHeader Vary header from the server
* @param {Record<string, string | string[]>} headers Request headers
* @returns {Record<string, string | string[] | null> | undefined}
*/
function parseVaryHeader (varyHeader, headers) {
if (hasVaryStar(varyHeader)) {
return headers
}
const output = /** @type {Record<string, string | string[] | null>} */ ({})
const varyingHeaders = splitVaryHeader(varyHeader)
for (const header of varyingHeaders) {
const trimmedHeader = trimOWS(header).toLowerCase()
if (trimmedHeader.length === 0) {
continue
}
if (!isValidHTTPToken(trimmedHeader)) {
return undefined
}
const headerValue = headers[trimmedHeader]
output[trimmedHeader] = Array.isArray(headerValue) ? headerValue.slice() : headerValue ?? null
}
return output
}
/**
* @param {string | string[]} varyHeader Vary header from the server
* @returns {boolean}
*/
function isInvalidOrWildcardVaryHeader (varyHeader) {
return hasVaryStar(varyHeader) || parseVaryHeader(varyHeader, {}) === undefined
}
/**
* Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
* however, including them in cached resposnes serves little to no purpose.
*
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
*
* @param {string} etag
* @returns {boolean}
*/
function isEtagUsable (etag) {
if (etag.length <= 2) {
// Shortest an etag can be is two chars (just ""). This is where we deviate
// from the spec requiring a min of 3 chars however
return false
}
if (etag[0] === '"' && etag[etag.length - 1] === '"') {
// ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the
// spec. Some servers will accept these while others don't.
// ETag: "asd123"
return !(etag[1] === '"' || etag.startsWith('"W/'))
}
if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') {
// ETag: W/"", also where we deviate from the spec & require a min of 3
// chars
// ETag: for W/"", W/"asd123"
return etag.length !== 4
}
// Anything else
return false
}
/**
* @param {unknown} store
* @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
*/
function assertCacheStore (store, name = 'CacheStore') {
if (typeof store !== 'object' || store === null) {
throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`)
}
for (const fn of ['get', 'createWriteStream', 'delete']) {
if (typeof store[fn] !== 'function') {
throw new TypeError(`${name} needs to have a \`${fn}()\` function`)
}
}
}
/**
* @param {unknown} methods
* @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]}
*/
function assertCacheMethods (methods, name = 'CacheMethods') {
if (!Array.isArray(methods)) {
throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`)
}
if (methods.length === 0) {
throw new TypeError(`${name} needs to have at least one method`)
}
for (const method of methods) {
if (!arrayIncludes(safeHTTPMethods, method)) {
throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`)
}
}
}
/**
* Creates a string key for request deduplication purposes.
* This key is used to identify in-flight requests that can be shared.
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
* @param {Set<string>} [excludeHeaders] Set of lowercase header names to exclude from the key
* @returns {string}
*/
function makeDeduplicationKey (cacheKey, excludeHeaders) {
// Use JSON.stringify to produce a collision-resistant key.
// Previous format used `:` and `=` delimiters without escaping, which
// allowed different header sets to produce identical keys (e.g.
// {a:"x:b=y"} vs {a:"x", b:"y"}). See: https://github.com/nodejs/undici/issues/5012
const headers = {}
if (cacheKey.headers) {
const sortedHeaders = Object.keys(cacheKey.headers).sort()
for (const header of sortedHeaders) {
// Skip excluded headers
if (excludeHeaders?.has(header.toLowerCase())) {
continue
}
headers[header] = cacheKey.headers[header]
}
}
return JSON.stringify([cacheKey.origin, cacheKey.method, cacheKey.path, headers])
}
module.exports = {
makeCacheKey,
normalizeHeaders,
assertCacheKey,
assertCacheValue,
parseCacheControlHeader,
hasInvalidCacheControlDirective,
parseVaryHeader,
hasVaryStar,
isInvalidOrWildcardVaryHeader,
isEtagUsable,
assertCacheMethods,
assertCacheStore,
makeDeduplicationKey
}