Spaces:
Paused
Paused
| const { | |
| safeHTTPMethods, | |
| pathHasQueryOrFragment, | |
| hasSafeIterator | |
| } = require('../core/util') | |
| const { serializePathWithQuery } = require('../core/util') | |
| /** | |
| * @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 | |
| } | |
| } | |
| /** | |
| * @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') | |
| } | |
| headers[key.toLowerCase()] = val | |
| } | |
| } else { | |
| for (const key of Object.keys(opts.headers)) { | |
| headers[key.toLowerCase()] = 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 = {} | |
| let directives | |
| if (Array.isArray(header)) { | |
| directives = [] | |
| for (const directive of header) { | |
| directives.push(...directive.split(',')) | |
| } | |
| } else { | |
| directives = header.split(',') | |
| } | |
| for (let i = 0; i < directives.length; i++) { | |
| const directive = directives[i].toLowerCase() | |
| const keyValueDelimiter = directive.indexOf('=') | |
| let key | |
| let value | |
| if (keyValueDelimiter !== -1) { | |
| key = directive.substring(0, keyValueDelimiter).trimStart() | |
| value = directive.substring(keyValueDelimiter + 1) | |
| } else { | |
| key = directive.trim() | |
| } | |
| switch (key) { | |
| case 'min-fresh': | |
| case 'max-stale': | |
| case 'max-age': | |
| case 's-maxage': | |
| case 'stale-while-revalidate': | |
| case 'stale-if-error': { | |
| if (value === undefined || value[0] === ' ') { | |
| continue | |
| } | |
| if ( | |
| value.length >= 2 && | |
| value[0] === '"' && | |
| value[value.length - 1] === '"' | |
| ) { | |
| value = value.substring(1, value.length - 1) | |
| } | |
| const parsedValue = parseInt(value, 10) | |
| // eslint-disable-next-line no-self-compare | |
| if (parsedValue !== parsedValue) { | |
| continue | |
| } | |
| if (key === 'max-age' && key in output && output[key] >= parsedValue) { | |
| continue | |
| } | |
| output[key] = parsedValue | |
| break | |
| } | |
| case 'private': | |
| case 'no-cache': { | |
| 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 ends in a | |
| // quote. We can then just splice all of the parts in between the | |
| // starting quote and the ending quote out of the directives array | |
| // 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"`. | |
| // Add the first header on and cut off the leading quote | |
| const headers = [value.substring(1)] | |
| let foundEndingQuote = value[value.length - 1] === '"' | |
| if (!foundEndingQuote) { | |
| // Something like `no-cache="some-header, another-header"` | |
| // This can still be something invalid, e.g. `no-cache="some-header, ...` | |
| for (let j = i + 1; j < directives.length; j++) { | |
| const nextPart = directives[j] | |
| const nextPartLength = nextPart.length | |
| headers.push(nextPart.trim()) | |
| if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') { | |
| foundEndingQuote = true | |
| break | |
| } | |
| } | |
| } | |
| if (foundEndingQuote) { | |
| let lastHeader = headers[headers.length - 1] | |
| if (lastHeader[lastHeader.length - 1] === '"') { | |
| lastHeader = lastHeader.substring(0, lastHeader.length - 1) | |
| headers[headers.length - 1] = lastHeader | |
| } | |
| for (let j = 0; j < headers.length; j++) { | |
| headers[j] = headers[j].trim() | |
| } | |
| if (key in output) { | |
| output[key] = output[key].concat(headers) | |
| } else { | |
| output[key] = headers | |
| } | |
| } | |
| } else { | |
| // Something like `no-cache="some-header"` | |
| const fieldName = value.trim() | |
| if (key in output) { | |
| output[key] = output[key].concat(fieldName) | |
| } else { | |
| output[key] = [fieldName] | |
| } | |
| } | |
| break | |
| } | |
| } | |
| // eslint-disable-next-line no-fallthrough | |
| case 'public': | |
| case 'no-store': | |
| case 'must-revalidate': | |
| case 'proxy-revalidate': | |
| case 'immutable': | |
| case 'no-transform': | |
| case 'must-understand': | |
| case 'only-if-cached': | |
| if (value) { | |
| // These are qualified (something like `public=...`) when they aren't | |
| // allowed to be, skip | |
| continue | |
| } | |
| 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 | |
| * @param {Record<string, string | string[]>} headers Request headers | |
| * @returns {Record<string, string | string[]>} | |
| */ | |
| function parseVaryHeader (varyHeader, headers) { | |
| if (typeof varyHeader === 'string' && varyHeader.includes('*')) { | |
| return headers | |
| } | |
| const output = /** @type {Record<string, string | string[] | null>} */ ({}) | |
| const varyingHeaders = typeof varyHeader === 'string' | |
| ? varyHeader.split(',') | |
| : varyHeader | |
| for (const header of varyingHeaders) { | |
| const trimmedHeader = header.trim().toLowerCase() | |
| output[trimmedHeader] = headers[trimmedHeader] ?? null | |
| } | |
| return output | |
| } | |
| /** | |
| * 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 (!safeHTTPMethods.includes(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, | |
| parseVaryHeader, | |
| isEtagUsable, | |
| assertCacheMethods, | |
| assertCacheStore, | |
| makeDeduplicationKey | |
| } | |