123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- 'use strict'
- const {
- safeHTTPMethods
- } = 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
- try {
- fullPath = serializePathWithQuery(opts.path || '/', opts.query)
- } catch (error) {
- // If fails (path already has query params), use as-is
- fullPath = opts.path || '/'
- }
- return {
- origin: opts.origin.toString(),
- method: opts.method,
- path: fullPath,
- headers: opts.headers
- }
- }
- /**
- * @param {Record<string, string[] | string>}
- * @returns {Record<string, string[] | string>}
- */
- function normaliseHeaders (opts) {
- let headers
- if (opts.headers == null) {
- headers = {}
- } else if (typeof opts.headers[Symbol.iterator] === 'function') {
- 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 if (typeof opts.headers === 'object') {
- headers = {}
- 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
- }
- if (key in output) {
- output[key] = output[key].concat(headers)
- } else {
- output[key] = headers
- }
- }
- } else {
- // Something like `no-cache=some-header`
- if (key in output) {
- output[key] = output[key].concat(value)
- } else {
- output[key] = [value]
- }
- }
- 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}`)
- }
- }
- }
- module.exports = {
- makeCacheKey,
- normaliseHeaders,
- assertCacheKey,
- assertCacheValue,
- parseCacheControlHeader,
- parseVaryHeader,
- isEtagUsable,
- assertCacheMethods,
- assertCacheStore
- }
|