| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676 |
- 'use strict'
- 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) {
- if (!opts.origin) {
- throw new Error('opts.origin is undefined')
- }
- let fullPath = opts.path || '/'
- if (opts.query && !pathHasQueryOrFragment(fullPath)) {
- fullPath = serializePathWithQuery(fullPath, opts.query)
- }
- return {
- origin: opts.origin.toString(),
- 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)) {
- 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
- }
|