Comparing version 7.0.0-alpha.7 to 7.0.0-alpha.8
@@ -1263,2 +1263,4 @@ # Dispatcher | ||
- `methods` - The [**safe** HTTP methods](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) to cache the response of. | ||
- `cacheByDefault` - The default expiration time to cache responses by if they don't have an explicit expiration. If this isn't present, responses without explicit expiration will not be cached. Default `undefined`. | ||
- `type` - The type of cache for Undici to act as. Can be `shared` or `private`. Default `shared`. | ||
@@ -1265,0 +1267,0 @@ ## Instance Events |
@@ -92,3 +92,5 @@ 'use strict' | ||
body: entry.body, | ||
vary: entry.vary ? entry.vary : undefined, | ||
etag: entry.etag, | ||
cacheControlDirectives: entry.cacheControlDirectives, | ||
cachedAt: entry.cachedAt, | ||
@@ -95,0 +97,0 @@ staleAt: entry.staleAt, |
@@ -7,4 +7,7 @@ 'use strict' | ||
const VERSION = 2 | ||
const VERSION = 3 | ||
// 2gb | ||
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000 | ||
/** | ||
@@ -21,4 +24,4 @@ * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore | ||
*/ | ||
class SqliteCacheStore { | ||
#maxEntrySize = Infinity | ||
module.exports = class SqliteCacheStore { | ||
#maxEntrySize = MAX_ENTRY_SIZE | ||
#maxCount = Infinity | ||
@@ -83,2 +86,7 @@ | ||
} | ||
if (opts.maxEntrySize > MAX_ENTRY_SIZE) { | ||
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb') | ||
} | ||
this.#maxEntrySize = opts.maxEntrySize | ||
@@ -109,3 +117,3 @@ } | ||
-- Data returned to the interceptor | ||
body TEXT NULL, | ||
body BUF NULL, | ||
deleteAt INTEGER NOT NULL, | ||
@@ -115,2 +123,3 @@ statusCode INTEGER NOT NULL, | ||
headers TEXT NULL, | ||
cacheControlDirectives TEXT NULL, | ||
etag TEXT NULL, | ||
@@ -136,2 +145,3 @@ vary TEXT NULL, | ||
etag, | ||
cacheControlDirectives, | ||
vary, | ||
@@ -156,2 +166,3 @@ cachedAt, | ||
etag = ?, | ||
cacheControlDirectives = ?, | ||
cachedAt = ?, | ||
@@ -174,2 +185,3 @@ staleAt = ?, | ||
etag, | ||
cacheControlDirectives, | ||
vary, | ||
@@ -179,3 +191,3 @@ cachedAt, | ||
deleteAt | ||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
`) | ||
@@ -230,3 +242,3 @@ | ||
const result = { | ||
body: value.body ? parseBufferArray(JSON.parse(value.body)) : null, | ||
body: Buffer.from(value.body), | ||
statusCode: value.statusCode, | ||
@@ -236,2 +248,6 @@ statusMessage: value.statusMessage, | ||
etag: value.etag ? value.etag : undefined, | ||
vary: value.vary ?? undefined, | ||
cacheControlDirectives: value.cacheControlDirectives | ||
? JSON.parse(value.cacheControlDirectives) | ||
: undefined, | ||
cachedAt: value.cachedAt, | ||
@@ -283,3 +299,3 @@ staleAt: value.staleAt, | ||
store.#updateValueQuery.run( | ||
JSON.stringify(stringifyBufferArray(body)), | ||
Buffer.concat(body), | ||
value.deleteAt, | ||
@@ -289,3 +305,4 @@ value.statusCode, | ||
value.headers ? JSON.stringify(value.headers) : null, | ||
value.etag, | ||
value.etag ? value.etag : null, | ||
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null, | ||
value.cachedAt, | ||
@@ -302,3 +319,3 @@ value.staleAt, | ||
key.method, | ||
JSON.stringify(stringifyBufferArray(body)), | ||
Buffer.concat(body), | ||
value.deleteAt, | ||
@@ -309,2 +326,3 @@ value.statusCode, | ||
value.etag ? value.etag : null, | ||
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null, | ||
value.vary ? JSON.stringify(value.vary) : null, | ||
@@ -334,3 +352,3 @@ value.cachedAt, | ||
#prune () { | ||
if (this.#size <= this.#maxCount) { | ||
if (this.size <= this.#maxCount) { | ||
return 0 | ||
@@ -360,3 +378,3 @@ } | ||
*/ | ||
get #size () { | ||
get size () { | ||
const { total } = this.#countEntriesQuery.get() | ||
@@ -405,6 +423,6 @@ return total | ||
const vary = JSON.parse(value.vary) | ||
value.vary = JSON.parse(value.vary) | ||
for (const header in vary) { | ||
if (headerValueEquals(headers[header], vary[header])) { | ||
for (const header in value.vary) { | ||
if (!headerValueEquals(headers[header], value.vary[header])) { | ||
matches = false | ||
@@ -447,30 +465,1 @@ break | ||
} | ||
/** | ||
* @param {Buffer[]} buffers | ||
* @returns {string[]} | ||
*/ | ||
function stringifyBufferArray (buffers) { | ||
const output = new Array(buffers.length) | ||
for (let i = 0; i < buffers.length; i++) { | ||
output[i] = buffers[i].toString() | ||
} | ||
return output | ||
} | ||
/** | ||
* @param {string[]} strings | ||
* @returns {Buffer[]} | ||
*/ | ||
function parseBufferArray (strings) { | ||
const output = new Array(strings.length) | ||
for (let i = 0; i < strings.length; i++) { | ||
output[i] = Buffer.from(strings[i]) | ||
} | ||
return output | ||
} | ||
module.exports = SqliteCacheStore |
@@ -13,3 +13,5 @@ 'use strict' | ||
/** | ||
* @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler} | ||
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler | ||
* | ||
* @implements {DispatchHandler} | ||
*/ | ||
@@ -23,2 +25,12 @@ class CacheHandler { | ||
/** | ||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']} | ||
*/ | ||
#cacheType | ||
/** | ||
* @type {number | undefined} | ||
*/ | ||
#cacheByDefault | ||
/** | ||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} | ||
@@ -43,6 +55,6 @@ */ | ||
*/ | ||
constructor (opts, cacheKey, handler) { | ||
const { store } = opts | ||
constructor ({ store, type, cacheByDefault }, cacheKey, handler) { | ||
this.#store = store | ||
this.#cacheType = type | ||
this.#cacheByDefault = cacheByDefault | ||
this.#cacheKey = cacheKey | ||
@@ -91,3 +103,3 @@ this.#handler = handler | ||
const cacheControlHeader = headers['cache-control'] | ||
if (!cacheControlHeader) { | ||
if (!cacheControlHeader && !headers['expires'] && !this.#cacheByDefault) { | ||
// Don't have the cache control header or the cache is full | ||
@@ -97,15 +109,38 @@ return downstreamOnHeaders() | ||
const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader) | ||
if (!canCacheResponse(statusCode, headers, cacheControlDirectives)) { | ||
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} | ||
if (!canCacheResponse(this.#cacheType, statusCode, headers, cacheControlDirectives)) { | ||
return downstreamOnHeaders() | ||
} | ||
const age = getAge(headers) | ||
const now = Date.now() | ||
const staleAt = determineStaleAt(now, headers, cacheControlDirectives) | ||
const staleAt = determineStaleAt(this.#cacheType, now, headers, cacheControlDirectives) ?? this.#cacheByDefault | ||
if (staleAt) { | ||
const varyDirectives = this.#cacheKey.headers && headers.vary | ||
? parseVaryHeader(headers.vary, this.#cacheKey.headers) | ||
: undefined | ||
const deleteAt = determineDeleteAt(now, cacheControlDirectives, staleAt) | ||
let baseTime = now | ||
if (headers['date']) { | ||
const parsedDate = parseInt(headers['date']) | ||
const date = new Date(isNaN(parsedDate) ? headers['date'] : parsedDate) | ||
if (date instanceof Date && !isNaN(date)) { | ||
baseTime = date.getTime() | ||
} | ||
} | ||
const absoluteStaleAt = staleAt + baseTime | ||
if (now >= absoluteStaleAt || (age && age >= staleAt)) { | ||
// Response is already stale | ||
return downstreamOnHeaders() | ||
} | ||
let varyDirectives | ||
if (this.#cacheKey.headers && headers.vary) { | ||
varyDirectives = parseVaryHeader(headers.vary, this.#cacheKey.headers) | ||
if (!varyDirectives) { | ||
// Parse error | ||
return downstreamOnHeaders() | ||
} | ||
} | ||
const deleteAt = determineDeleteAt(cacheControlDirectives, absoluteStaleAt) | ||
const strippedHeaders = stripNecessaryHeaders(headers, cacheControlDirectives) | ||
@@ -121,4 +156,5 @@ | ||
vary: varyDirectives, | ||
cachedAt: now, | ||
staleAt, | ||
cacheControlDirectives, | ||
cachedAt: age ? now - (age * 1000) : now, | ||
staleAt: absoluteStaleAt, | ||
deleteAt | ||
@@ -139,2 +175,3 @@ } | ||
// TODO (fix): Make error somehow observable? | ||
handler.#writeStream = undefined | ||
}) | ||
@@ -178,7 +215,8 @@ .on('close', function () { | ||
* | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType | ||
* @param {number} statusCode | ||
* @param {Record<string, string | string[]>} headers | ||
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives | ||
*/ | ||
function canCacheResponse (statusCode, headers, cacheControlDirectives) { | ||
function canCacheResponse (cacheType, statusCode, headers, cacheControlDirectives) { | ||
if (statusCode !== 200 && statusCode !== 307) { | ||
@@ -189,3 +227,2 @@ return false | ||
if ( | ||
cacheControlDirectives.private === true || | ||
cacheControlDirectives['no-cache'] === true || | ||
@@ -197,4 +234,8 @@ cacheControlDirectives['no-store'] | ||
if (cacheType === 'shared' && cacheControlDirectives.private === true) { | ||
return false | ||
} | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 | ||
if (headers.vary === '*') { | ||
if (headers.vary?.includes('*')) { | ||
return false | ||
@@ -228,35 +269,59 @@ } | ||
/** | ||
* @param {Record<string, string | string[]>} headers | ||
* @returns {number | undefined} | ||
*/ | ||
function getAge (headers) { | ||
if (!headers.age) { | ||
return undefined | ||
} | ||
const age = parseInt(Array.isArray(headers.age) ? headers.age[0] : headers.age) | ||
if (isNaN(age) || age >= 2147483647) { | ||
return undefined | ||
} | ||
return age | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType | ||
* @param {number} now | ||
* @param {Record<string, string | string[]>} headers | ||
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives | ||
* | ||
* @returns {number | undefined} time that the value is stale at or undefined if it shouldn't be cached | ||
*/ | ||
function determineStaleAt (now, headers, cacheControlDirectives) { | ||
// Prioritize s-maxage since we're a shared cache | ||
// s-maxage > max-age > Expire | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 | ||
const sMaxAge = cacheControlDirectives['s-maxage'] | ||
if (sMaxAge) { | ||
return now + (sMaxAge * 1000) | ||
function determineStaleAt (cacheType, now, headers, cacheControlDirectives) { | ||
if (cacheType === 'shared') { | ||
// Prioritize s-maxage since we're a shared cache | ||
// s-maxage > max-age > Expire | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 | ||
const sMaxAge = cacheControlDirectives['s-maxage'] | ||
if (sMaxAge) { | ||
return sMaxAge * 1000 | ||
} | ||
} | ||
if (cacheControlDirectives.immutable) { | ||
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 | ||
return now + 31536000 | ||
} | ||
const maxAge = cacheControlDirectives['max-age'] | ||
if (maxAge) { | ||
return now + (maxAge * 1000) | ||
return maxAge * 1000 | ||
} | ||
if (headers.expire && typeof headers.expire === 'string') { | ||
if (headers.expires && typeof headers.expires === 'string') { | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 | ||
const expiresDate = new Date(headers.expire) | ||
const expiresDate = new Date(headers.expires) | ||
if (expiresDate instanceof Date && Number.isFinite(expiresDate.valueOf())) { | ||
return now + (Date.now() - expiresDate.getTime()) | ||
if (now >= expiresDate.getTime()) { | ||
return undefined | ||
} | ||
return expiresDate.getTime() - now | ||
} | ||
} | ||
if (cacheControlDirectives.immutable) { | ||
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 | ||
return 31536000 | ||
} | ||
return undefined | ||
@@ -266,12 +331,23 @@ } | ||
/** | ||
* @param {number} now | ||
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives | ||
* @param {number} staleAt | ||
*/ | ||
function determineDeleteAt (now, cacheControlDirectives, staleAt) { | ||
function determineDeleteAt (cacheControlDirectives, staleAt) { | ||
let staleWhileRevalidate = -Infinity | ||
let staleIfError = -Infinity | ||
let immutable = -Infinity | ||
if (cacheControlDirectives['stale-while-revalidate']) { | ||
return now + (cacheControlDirectives['stale-while-revalidate'] * 1000) | ||
staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000) | ||
} | ||
return staleAt | ||
if (cacheControlDirectives['stale-if-error']) { | ||
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000) | ||
} | ||
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { | ||
immutable = 31536000 | ||
} | ||
return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) | ||
} | ||
@@ -282,8 +358,30 @@ | ||
* @param {Record<string, string | string[]>} headers | ||
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives | ||
* @returns {Record<string, string | string []>} | ||
*/ | ||
function stripNecessaryHeaders (headers, cacheControlDirectives) { | ||
const headersToRemove = ['connection'] | ||
const headersToRemove = [ | ||
'connection', | ||
'proxy-authenticate', | ||
'proxy-authentication-info', | ||
'proxy-authorization', | ||
'proxy-connection', | ||
'te', | ||
'transfer-encoding', | ||
'upgrade', | ||
// We'll add age back when serving it | ||
'age' | ||
] | ||
if (headers['connection']) { | ||
if (Array.isArray(headers['connection'])) { | ||
// connection: a | ||
// connection: b | ||
headersToRemove.push(...headers['connection'].map(header => header.trim())) | ||
} else { | ||
// connection: a, b | ||
headersToRemove.push(...headers['connection'].split(',').map(header => header.trim())) | ||
} | ||
} | ||
if (Array.isArray(cacheControlDirectives['no-cache'])) { | ||
@@ -298,8 +396,9 @@ headersToRemove.push(...cacheControlDirectives['no-cache']) | ||
let strippedHeaders | ||
for (const headerName of Object.keys(headers)) { | ||
if (headersToRemove.includes(headerName)) { | ||
for (const headerName of headersToRemove) { | ||
if (headers[headerName]) { | ||
strippedHeaders ??= { ...headers } | ||
delete headers[headerName] | ||
delete strippedHeaders[headerName] | ||
} | ||
} | ||
return strippedHeaders ?? headers | ||
@@ -306,0 +405,0 @@ } |
@@ -20,2 +20,3 @@ 'use strict' | ||
#successful = false | ||
/** | ||
@@ -25,2 +26,3 @@ * @type {((boolean, any) => void) | null} | ||
#callback | ||
/** | ||
@@ -34,6 +36,12 @@ * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandler)} | ||
/** | ||
* @param {(boolean, any) => void} callback Function to call if the cached value is valid | ||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler | ||
* @type {boolean} | ||
*/ | ||
constructor (callback, handler) { | ||
#allowErrorStatusCodes | ||
/** | ||
* @param {(boolean) => void} callback Function to call if the cached value is valid | ||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler | ||
* @param {boolean} allowErrorStatusCodes | ||
*/ | ||
constructor (callback, handler, allowErrorStatusCodes) { | ||
if (typeof callback !== 'function') { | ||
@@ -45,5 +53,6 @@ throw new TypeError('callback must be a function') | ||
this.#handler = handler | ||
this.#allowErrorStatusCodes = allowErrorStatusCodes | ||
} | ||
onRequestStart (controller, context) { | ||
onRequestStart (_, context) { | ||
this.#successful = false | ||
@@ -66,3 +75,5 @@ this.#context = context | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo | ||
this.#successful = statusCode === 304 | ||
// https://datatracker.ietf.org/doc/html/rfc5861#section-4 | ||
this.#successful = statusCode === 304 || | ||
(this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504) | ||
this.#callback(this.#successful, this.#context) | ||
@@ -69,0 +80,0 @@ this.#callback = null |
@@ -5,2 +5,5 @@ 'use strict' | ||
/** | ||
* @deprecated | ||
*/ | ||
module.exports = class DecoratorHandler { | ||
@@ -7,0 +10,0 @@ #handler |
@@ -45,3 +45,2 @@ 'use strict' | ||
this.location = null | ||
this.abort = null | ||
this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy | ||
@@ -87,16 +86,11 @@ this.maxRedirections = maxRedirections | ||
onConnect (abort) { | ||
this.abort = abort | ||
this.handler.onConnect(abort, { history: this.history }) | ||
onRequestStart (controller, context) { | ||
this.handler.onRequestStart?.(controller, { ...context, history: this.history }) | ||
} | ||
onUpgrade (statusCode, headers, socket) { | ||
this.handler.onUpgrade(statusCode, headers, socket) | ||
onRequestUpgrade (controller, statusCode, headers, socket) { | ||
this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket) | ||
} | ||
onError (error) { | ||
this.handler.onError(error) | ||
} | ||
onHeaders (statusCode, rawHeaders, resume, statusText) { | ||
onResponseStart (controller, statusCode, statusMessage, headers) { | ||
if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) { | ||
@@ -127,5 +121,5 @@ throw new Error('max redirects') | ||
this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) | ||
this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) || redirectableStatusCodes.indexOf(statusCode) === -1 | ||
? null | ||
: parseLocation(statusCode, rawHeaders) | ||
: headers.location | ||
@@ -137,3 +131,4 @@ if (this.opts.origin) { | ||
if (!this.location) { | ||
return this.handler.onHeaders(statusCode, rawHeaders, resume, statusText) | ||
this.handler.onResponseStart?.(controller, statusCode, statusMessage, headers) | ||
return | ||
} | ||
@@ -154,3 +149,3 @@ | ||
onData (chunk) { | ||
onResponseData (controller, chunk) { | ||
if (this.location) { | ||
@@ -175,7 +170,7 @@ /* | ||
} else { | ||
return this.handler.onData(chunk) | ||
this.handler.onResponseData?.(controller, chunk) | ||
} | ||
} | ||
onComplete (trailers) { | ||
onResponseEnd (controller, trailers) { | ||
if (this.location) { | ||
@@ -190,31 +185,13 @@ /* | ||
*/ | ||
this.location = null | ||
this.abort = null | ||
this.dispatch(this.opts, this) | ||
} else { | ||
this.handler.onComplete(trailers) | ||
this.handler.onResponseEnd(controller, trailers) | ||
} | ||
} | ||
onBodySent (chunk) { | ||
if (this.handler.onBodySent) { | ||
this.handler.onBodySent(chunk) | ||
} | ||
onResponseError (controller, error) { | ||
this.handler.onResponseError?.(controller, error) | ||
} | ||
} | ||
function parseLocation (statusCode, rawHeaders) { | ||
if (redirectableStatusCodes.indexOf(statusCode) === -1) { | ||
return null | ||
} | ||
for (let i = 0; i < rawHeaders.length; i += 2) { | ||
if (rawHeaders[i].length === 8 && util.headerNameToString(rawHeaders[i]) === 'location') { | ||
return rawHeaders[i + 1] | ||
} | ||
} | ||
} | ||
// https://tools.ietf.org/html/rfc7231#section-6.4.4 | ||
@@ -221,0 +198,0 @@ function shouldRemoveHeader (header, removeContent, unknownOrigin) { |
@@ -90,3 +90,3 @@ 'use strict' | ||
onError (err) { | ||
if (!this.#handler.onError) { | ||
if (!this.#handler.onResponseError) { | ||
throw new InvalidArgumentError('invalid onError method') | ||
@@ -93,0 +93,0 @@ } |
@@ -13,41 +13,11 @@ 'use strict' | ||
/** | ||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler | ||
* @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn | ||
*/ | ||
function sendGatewayTimeout (handler) { | ||
let aborted = false | ||
try { | ||
if (typeof handler.onConnect === 'function') { | ||
handler.onConnect(() => { | ||
aborted = true | ||
}) | ||
if (aborted) { | ||
return | ||
} | ||
} | ||
if (typeof handler.onHeaders === 'function') { | ||
handler.onHeaders(504, [], () => {}, 'Gateway Timeout') | ||
if (aborted) { | ||
return | ||
} | ||
} | ||
if (typeof handler.onComplete === 'function') { | ||
handler.onComplete([]) | ||
} | ||
} catch (err) { | ||
if (typeof handler.onError === 'function') { | ||
handler.onError(err) | ||
} | ||
} | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result | ||
* @param {number} age | ||
* @param {import('../util/cache.js').CacheControlDirectives | undefined} cacheControlDirectives | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives | ||
* @returns {boolean} | ||
*/ | ||
function needsRevalidation (result, age, cacheControlDirectives) { | ||
function needsRevalidation (result, cacheControlDirectives) { | ||
if (cacheControlDirectives?.['no-cache']) { | ||
@@ -86,2 +56,218 @@ // Always revalidate requests with the no-cache directive | ||
/** | ||
* @param {DispatchFn} dispatch | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey | ||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler | ||
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl | ||
*/ | ||
function handleUncachedResponse ( | ||
dispatch, | ||
globalOpts, | ||
cacheKey, | ||
handler, | ||
opts, | ||
reqCacheControl | ||
) { | ||
if (reqCacheControl?.['only-if-cached']) { | ||
let aborted = false | ||
try { | ||
if (typeof handler.onConnect === 'function') { | ||
handler.onConnect(() => { | ||
aborted = true | ||
}) | ||
if (aborted) { | ||
return | ||
} | ||
} | ||
if (typeof handler.onHeaders === 'function') { | ||
handler.onHeaders(504, [], () => {}, 'Gateway Timeout') | ||
if (aborted) { | ||
return | ||
} | ||
} | ||
if (typeof handler.onComplete === 'function') { | ||
handler.onComplete([]) | ||
} | ||
} catch (err) { | ||
if (typeof handler.onError === 'function') { | ||
handler.onError(err) | ||
} | ||
} | ||
return true | ||
} | ||
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result | ||
* @param {number} age | ||
*/ | ||
function sendCachedValue (handler, opts, result, age, context) { | ||
// TODO (perf): Readable.from path can be optimized... | ||
const stream = util.isStream(result.body) | ||
? result.body | ||
: Readable.from(result.body ?? []) | ||
assert(!stream.destroyed, 'stream should not be destroyed') | ||
assert(!stream.readableDidRead, 'stream should not be readableDidRead') | ||
const controller = { | ||
resume () { | ||
stream.resume() | ||
}, | ||
pause () { | ||
stream.pause() | ||
}, | ||
get paused () { | ||
return stream.isPaused() | ||
}, | ||
get aborted () { | ||
return stream.destroyed | ||
}, | ||
get reason () { | ||
return stream.errored | ||
}, | ||
abort (reason) { | ||
stream.destroy(reason ?? new AbortError()) | ||
} | ||
} | ||
stream | ||
.on('error', function (err) { | ||
if (!this.readableEnded) { | ||
if (typeof handler.onResponseError === 'function') { | ||
handler.onResponseError(controller, err) | ||
} else { | ||
throw err | ||
} | ||
} | ||
}) | ||
.on('close', function () { | ||
if (!this.errored) { | ||
handler.onResponseEnd?.(controller, {}) | ||
} | ||
}) | ||
handler.onRequestStart?.(controller, context) | ||
if (stream.destroyed) { | ||
return | ||
} | ||
// Add the age header | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-age | ||
// TODO (fix): What if headers.age already exists? | ||
const headers = age != null ? { ...result.headers, age: String(age) } : result.headers | ||
handler.onResponseStart?.(controller, result.statusCode, result.statusMessage, headers) | ||
if (opts.method === 'HEAD') { | ||
stream.destroy() | ||
} else { | ||
stream.on('data', function (chunk) { | ||
handler.onResponseData?.(controller, chunk) | ||
}) | ||
} | ||
} | ||
/** | ||
* @param {DispatchFn} dispatch | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey | ||
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler | ||
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl | ||
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} result | ||
*/ | ||
function handleResult ( | ||
dispatch, | ||
globalOpts, | ||
cacheKey, | ||
handler, | ||
opts, | ||
reqCacheControl, | ||
result | ||
) { | ||
if (!result) { | ||
return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl) | ||
} | ||
if (!result.body && opts.method !== 'HEAD') { | ||
throw new Error('body is undefined but method isn\'t HEAD') | ||
} | ||
const now = Date.now() | ||
if (now > result.deleteAt) { | ||
// Response is expired, cache store shouldn't have given this to us | ||
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} | ||
const age = Math.round((now - result.cachedAt) / 1000) | ||
if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) { | ||
// Response is considered expired for this specific request | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 | ||
return dispatch(opts, handler) | ||
} | ||
// Check if the response is stale | ||
if (needsRevalidation(result, reqCacheControl)) { | ||
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) { | ||
// If body is is stream we can't revalidate... | ||
// TODO (fix): This could be less strict... | ||
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} | ||
let withinStaleIfErrorThreshold = false | ||
const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] | ||
if (staleIfErrorExpiry) { | ||
withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) | ||
} | ||
let headers = { | ||
...opts.headers, | ||
'if-modified-since': new Date(result.cachedAt).toUTCString(), | ||
'if-none-match': result.etag | ||
} | ||
if (result.vary) { | ||
headers = { | ||
...headers, | ||
...result.vary | ||
} | ||
} | ||
// We need to revalidate the response | ||
return dispatch( | ||
{ | ||
...opts, | ||
headers | ||
}, | ||
new CacheRevalidationHandler( | ||
(success, context) => { | ||
if (success) { | ||
sendCachedValue(handler, opts, result, age, context) | ||
} else if (util.isStream(result.body)) { | ||
result.body.on('error', () => {}).destroy() | ||
} | ||
}, | ||
new CacheHandler(globalOpts, cacheKey, handler), | ||
withinStaleIfErrorThreshold | ||
) | ||
) | ||
} | ||
// Dump request body. | ||
if (util.isStream(opts.body)) { | ||
opts.body.on('error', () => {}).destroy() | ||
} | ||
sendCachedValue(handler, opts, result, age, null) | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts] | ||
@@ -93,3 +279,5 @@ * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor} | ||
store = new MemoryCacheStore(), | ||
methods = ['GET'] | ||
methods = ['GET'], | ||
cacheByDefault = undefined, | ||
type = 'shared' | ||
} = opts | ||
@@ -104,5 +292,15 @@ | ||
if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') { | ||
throw new TypeError(`exepcted opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`) | ||
} | ||
if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') { | ||
throw new TypeError(`exepcted opts.type to be shared, private, or undefined, got ${typeof type}`) | ||
} | ||
const globalOpts = { | ||
store, | ||
methods | ||
methods, | ||
cacheByDefault, | ||
type | ||
} | ||
@@ -114,5 +312,2 @@ | ||
return (opts, handler) => { | ||
// TODO (fix): What if e.g. opts.headers has if-modified-since header? Or other headers | ||
// that make things ambigious? | ||
if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) { | ||
@@ -123,7 +318,7 @@ // Not a method we want to cache or we don't have the origin, skip | ||
const requestCacheControl = opts.headers?.['cache-control'] | ||
const reqCacheControl = opts.headers?.['cache-control'] | ||
? parseCacheControlHeader(opts.headers['cache-control']) | ||
: undefined | ||
if (requestCacheControl?.['no-store']) { | ||
if (reqCacheControl?.['no-store']) { | ||
return dispatch(opts, handler) | ||
@@ -136,168 +331,25 @@ } | ||
const cacheKey = makeCacheKey(opts) | ||
// TODO (perf): For small entries support returning a Buffer instead of a stream. | ||
// Maybe store should return { staleAt, headers, body, etc... } instead of a stream + stream.value? | ||
// Where body can be a Buffer, string, stream or blob? | ||
const result = store.get(cacheKey) | ||
if (!result) { | ||
if (requestCacheControl?.['only-if-cached']) { | ||
// We only want cached responses | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached | ||
sendGatewayTimeout(handler) | ||
return true | ||
} | ||
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result | ||
* @param {number} age | ||
*/ | ||
const respondWithCachedValue = ({ headers, statusCode, statusMessage, body }, age, context) => { | ||
const stream = util.isStream(body) | ||
? body | ||
: Readable.from(body ?? []) | ||
assert(!stream.destroyed, 'stream should not be destroyed') | ||
assert(!stream.readableDidRead, 'stream should not be readableDidRead') | ||
const controller = { | ||
resume () { | ||
stream.resume() | ||
}, | ||
pause () { | ||
stream.pause() | ||
}, | ||
get paused () { | ||
return stream.isPaused() | ||
}, | ||
get aborted () { | ||
return stream.destroyed | ||
}, | ||
get reason () { | ||
return stream.errored | ||
}, | ||
abort (reason) { | ||
stream.destroy(reason ?? new AbortError()) | ||
} | ||
} | ||
stream | ||
.on('error', function (err) { | ||
if (!this.readableEnded) { | ||
if (typeof handler.onResponseError === 'function') { | ||
handler.onResponseError(controller, err) | ||
} else { | ||
throw err | ||
} | ||
} | ||
}) | ||
.on('close', function () { | ||
if (!this.errored) { | ||
handler.onResponseEnd?.(controller, {}) | ||
} | ||
}) | ||
handler.onRequestStart?.(controller, context) | ||
if (stream.destroyed) { | ||
return | ||
} | ||
// Add the age header | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-age | ||
// TODO (fix): What if headers.age already exists? | ||
headers = age != null ? { ...headers, age: String(age) } : headers | ||
handler.onResponseStart?.(controller, statusCode, statusMessage, headers) | ||
if (opts.method === 'HEAD') { | ||
stream.destroy() | ||
} else { | ||
stream.on('data', function (chunk) { | ||
handler.onResponseData?.(controller, chunk) | ||
}) | ||
} | ||
} | ||
/** | ||
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result | ||
*/ | ||
const handleResult = (result) => { | ||
// TODO (perf): Readable.from path can be optimized... | ||
if (!result.body && opts.method !== 'HEAD') { | ||
throw new Error('stream is undefined but method isn\'t HEAD') | ||
} | ||
const age = Math.round((Date.now() - result.cachedAt) / 1000) | ||
if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) { | ||
// Response is considered expired for this specific request | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 | ||
return dispatch(opts, handler) | ||
} | ||
// Check if the response is stale | ||
if (needsRevalidation(result, age, requestCacheControl)) { | ||
if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) { | ||
// If body is is stream we can't revalidate... | ||
// TODO (fix): This could be less strict... | ||
return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} | ||
// We need to revalidate the response | ||
return dispatch( | ||
{ | ||
...opts, | ||
headers: { | ||
...opts.headers, | ||
'if-modified-since': new Date(result.cachedAt).toUTCString(), | ||
etag: result.etag | ||
} | ||
}, | ||
new CacheRevalidationHandler( | ||
(success, context) => { | ||
if (success) { | ||
respondWithCachedValue(result, age, context) | ||
} else if (util.isStream(result.body)) { | ||
result.body.on('error', () => {}).destroy() | ||
} | ||
}, | ||
new CacheHandler(globalOpts, cacheKey, handler) | ||
) | ||
if (result && typeof result.then === 'function') { | ||
result.then(result => { | ||
handleResult(dispatch, | ||
globalOpts, | ||
cacheKey, | ||
handler, | ||
opts, | ||
reqCacheControl, | ||
result | ||
) | ||
} | ||
// Dump request body. | ||
if (util.isStream(opts.body)) { | ||
opts.body.on('error', () => {}).destroy() | ||
} | ||
respondWithCachedValue(result, age, null) | ||
} | ||
if (typeof result.then === 'function') { | ||
result.then((result) => { | ||
if (!result) { | ||
if (requestCacheControl?.['only-if-cached']) { | ||
// We only want cached responses | ||
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached | ||
sendGatewayTimeout(handler) | ||
return true | ||
} | ||
dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) | ||
} else { | ||
handleResult(result) | ||
} | ||
}, err => { | ||
if (typeof handler.onError === 'function') { | ||
handler.onError(err) | ||
} else { | ||
throw err | ||
} | ||
}) | ||
} else { | ||
handleResult(result) | ||
handleResult( | ||
dispatch, | ||
globalOpts, | ||
cacheKey, | ||
handler, | ||
opts, | ||
reqCacheControl, | ||
result | ||
) | ||
} | ||
@@ -304,0 +356,0 @@ |
@@ -99,32 +99,23 @@ 'use strict' | ||
* @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml | ||
* | ||
* @typedef {{ | ||
* 'max-stale'?: number; | ||
* 'min-fresh'?: number; | ||
* 'max-age'?: number; | ||
* 's-maxage'?: number; | ||
* 'stale-while-revalidate'?: number; | ||
* 'stale-if-error'?: number; | ||
* public?: true; | ||
* private?: true | string[]; | ||
* 'no-store'?: true; | ||
* 'no-cache'?: true | string[]; | ||
* 'must-revalidate'?: true; | ||
* 'proxy-revalidate'?: true; | ||
* immutable?: true; | ||
* 'no-transform'?: true; | ||
* 'must-understand'?: true; | ||
* 'only-if-cached'?: true; | ||
* }} CacheControlDirectives | ||
* | ||
* @param {string | string[]} header | ||
* @returns {CacheControlDirectives} | ||
* @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} | ||
*/ | ||
function parseCacheControlHeader (header) { | ||
/** | ||
* @type {import('../util/cache.js').CacheControlDirectives} | ||
* @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} | ||
*/ | ||
const output = {} | ||
const directives = Array.isArray(header) ? header : header.split(',') | ||
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++) { | ||
@@ -137,6 +128,4 @@ const directive = directives[i].toLowerCase() | ||
if (keyValueDelimiter !== -1) { | ||
key = directive.substring(0, keyValueDelimiter).trim() | ||
value = directive | ||
.substring(keyValueDelimiter + 1) | ||
.trim() | ||
key = directive.substring(0, keyValueDelimiter).trimStart() | ||
value = directive.substring(keyValueDelimiter + 1) | ||
} else { | ||
@@ -153,6 +142,14 @@ key = directive.trim() | ||
case 'stale-if-error': { | ||
if (value === undefined) { | ||
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) | ||
@@ -164,2 +161,6 @@ // eslint-disable-next-line no-self-compare | ||
if (key === 'max-age' && key in output && output[key] >= parsedValue) { | ||
continue | ||
} | ||
output[key] = parsedValue | ||
@@ -213,7 +214,15 @@ | ||
output[key] = headers | ||
if (key in output) { | ||
output[key] = output[key].concat(headers) | ||
} else { | ||
output[key] = headers | ||
} | ||
} | ||
} else { | ||
// Something like `no-cache=some-header` | ||
output[key] = [value] | ||
if (key in output) { | ||
output[key] = output[key].concat(value) | ||
} else { | ||
output[key] = [value] | ||
} | ||
} | ||
@@ -256,3 +265,3 @@ | ||
function parseVaryHeader (varyHeader, headers) { | ||
if (typeof varyHeader === 'string' && varyHeader === '*') { | ||
if (typeof varyHeader === 'string' && varyHeader.includes('*')) { | ||
return headers | ||
@@ -271,2 +280,4 @@ } | ||
output[trimmedHeader] = headers[trimmedHeader] | ||
} else { | ||
return undefined | ||
} | ||
@@ -273,0 +284,0 @@ } |
{ | ||
"name": "undici", | ||
"version": "7.0.0-alpha.7", | ||
"version": "7.0.0-alpha.8", | ||
"description": "An HTTP/1.1 client, written from scratch for Node.js", | ||
@@ -76,2 +76,3 @@ "homepage": "https://undici.nodejs.org", | ||
"test:cache": "borp -p \"test/cache/*.js\"", | ||
"test:sqlite": "NODE_OPTIONS=--experimental-sqlite borp -p \"test/cache-interceptor/*.js\"", | ||
"test:cache-interceptor": "borp -p \"test/cache-interceptor/*.js\"", | ||
@@ -114,3 +115,3 @@ "test:cookies": "borp -p \"test/cookie/*.js\"", | ||
"abort-controller": "^3.0.0", | ||
"borp": "^0.18.0", | ||
"borp": "^0.19.0", | ||
"c8": "^10.0.0", | ||
@@ -133,3 +134,3 @@ "cross-env": "^7.0.3", | ||
"engines": { | ||
"node": ">=18.17" | ||
"node": ">=20.18.1" | ||
}, | ||
@@ -136,0 +137,0 @@ "tsd": { |
@@ -10,2 +10,6 @@ import { Readable, Writable } from 'node:stream' | ||
store: CacheStore | ||
cacheByDefault?: number | ||
type?: CacheOptions['type'] | ||
} | ||
@@ -24,4 +28,37 @@ | ||
methods?: CacheMethods[] | ||
/** | ||
* RFC9111 allows for caching responses that we aren't explicitly told to | ||
* cache or to not cache. | ||
* @see https://www.rfc-editor.org/rfc/rfc9111.html#section-3-5 | ||
* @default undefined | ||
*/ | ||
cacheByDefault?: number | ||
/** | ||
* TODO docs | ||
* @default 'shared' | ||
*/ | ||
type?: 'shared' | 'private' | ||
} | ||
export interface CacheControlDirectives { | ||
'max-stale'?: number; | ||
'min-fresh'?: number; | ||
'max-age'?: number; | ||
's-maxage'?: number; | ||
'stale-while-revalidate'?: number; | ||
'stale-if-error'?: number; | ||
public?: true; | ||
private?: true | string[]; | ||
'no-store'?: true; | ||
'no-cache'?: true | string[]; | ||
'must-revalidate'?: true; | ||
'proxy-revalidate'?: true; | ||
immutable?: true; | ||
'no-transform'?: true; | ||
'must-understand'?: true; | ||
'only-if-cached'?: true; | ||
} | ||
export interface CacheKey { | ||
@@ -40,2 +77,3 @@ origin: string | ||
etag?: string | ||
cacheControlDirectives?: CacheControlDirectives | ||
cachedAt: number | ||
@@ -56,4 +94,6 @@ staleAt: number | ||
headers: Record<string, string | string[]> | ||
vary?: Record<string, string | string[]> | ||
etag?: string | ||
body: null | Readable | Iterable<Buffer> | AsyncIterable<Buffer> | Buffer | Iterable<string> | AsyncIterable<string> | string | ||
cacheControlDirectives: CacheControlDirectives, | ||
cachedAt: number | ||
@@ -60,0 +100,0 @@ staleAt: number |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
1287202
27785
56