Skip to content

Commit

Permalink
test: add cache testing suite (#3842)
Browse files Browse the repository at this point in the history
* test: add cache testing suite

Closes #3852
Closes #3869

Signed-off-by: flakey5 <[email protected]>

* some cleanup

Signed-off-by: flakey5 <[email protected]>

* docs

Signed-off-by: flakey5 <[email protected]>

* Update test/cache-interceptor/cache-tests.mjs

Co-authored-by: Khafra <[email protected]>

* fetch

Signed-off-by: flakey5 <[email protected]>

---------

Signed-off-by: flakey5 <[email protected]>
Co-authored-by: Khafra <[email protected]>
  • Loading branch information
flakey5 and KhafraDev authored Nov 26, 2024
1 parent 1b58a51 commit 0fd1520
Show file tree
Hide file tree
Showing 156 changed files with 69,592 additions and 65 deletions.
2 changes: 2 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,8 @@ The `cache` interceptor implements client-side response caching as described in
- `store` - The [`CacheStore`](/docs/docs/api/CacheStore.md) to store and retrieve responses from. Default is [`MemoryCacheStore`](/docs/docs/api/CacheStore.md#memorycachestore).
- `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`.
## Instance Events
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = [
ignores: [
'lib/llhttp',
'test/fixtures/wpt',
'test/fixtures/cache-tests',
'undici-fetch.js'
],
noJsx: true,
Expand Down
1 change: 1 addition & 0 deletions lib/cache/memory-cache-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class MemoryCacheStore {
statusCode: entry.statusCode,
headers: entry.headers,
body: entry.body,
vary: entry.vary ? entry.vary : undefined,
etag: entry.etag,
cacheControlDirectives: entry.cacheControlDirectives,
cachedAt: entry.cachedAt,
Expand Down
17 changes: 13 additions & 4 deletions lib/cache/sqlite-cache-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const { assertCacheKey, assertCacheValue } = require('../util/cache.js')

const VERSION = 3

// 2gb
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000

/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @implements {CacheStore}
Expand All @@ -18,7 +21,7 @@ const VERSION = 3
* } & import('../../types/cache-interceptor.d.ts').default.CacheValue} SqliteStoreValue
*/
module.exports = class SqliteCacheStore {
#maxEntrySize = Infinity
#maxEntrySize = MAX_ENTRY_SIZE
#maxCount = Infinity

/**
Expand Down Expand Up @@ -78,6 +81,11 @@ module.exports = class SqliteCacheStore {
) {
throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer')
}

if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
}

this.#maxEntrySize = opts.maxEntrySize
}

Expand Down Expand Up @@ -227,6 +235,7 @@ module.exports = class SqliteCacheStore {
statusMessage: value.statusMessage,
headers: value.headers ? JSON.parse(value.headers) : undefined,
etag: value.etag ? value.etag : undefined,
vary: value.vary ?? undefined,
cacheControlDirectives: value.cacheControlDirectives
? JSON.parse(value.cacheControlDirectives)
: undefined,
Expand Down Expand Up @@ -394,10 +403,10 @@ module.exports = class SqliteCacheStore {
return undefined
}

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
break
}
Expand Down
173 changes: 133 additions & 40 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@ const {
function noop () {}

/**
* @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler}
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
*
* @implements {DispatchHandler}
*/
class CacheHandler {
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
*/
#cacheKey

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']}
*/
#cacheType

/**
* @type {number | undefined}
*/
#cacheByDefault

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStore}
*/
Expand All @@ -38,8 +50,10 @@ class CacheHandler {
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler
*/
constructor ({ store }, cacheKey, handler) {
constructor ({ store, type, cacheByDefault }, cacheKey, handler) {
this.#store = store
this.#cacheType = type
this.#cacheByDefault = cacheByDefault
this.#cacheKey = cacheKey
this.#handler = handler
}
Expand Down Expand Up @@ -83,24 +97,47 @@ class CacheHandler {
}

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
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)

/**
Expand All @@ -112,8 +149,8 @@ class CacheHandler {
headers: strippedHeaders,
vary: varyDirectives,
cacheControlDirectives,
cachedAt: now,
staleAt,
cachedAt: age ? now - (age * 1000) : now,
staleAt: absoluteStaleAt,
deleteAt
}

Expand All @@ -129,6 +166,7 @@ class CacheHandler {
.on('drain', () => controller.resume())
.on('error', function () {
// TODO (fix): Make error somehow observable?
handler.#writeStream = undefined
})
.on('close', function () {
if (handler.#writeStream === this) {
Expand Down Expand Up @@ -167,25 +205,29 @@ class CacheHandler {
/**
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
*
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
* @param {number} statusCode
* @param {Record<string, string | string[]>} headers
* @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) {
return false
}

if (
cacheControlDirectives.private === true ||
cacheControlDirectives['no-cache'] === true ||
cacheControlDirectives['no-store']
) {
return false
}

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
}

Expand Down Expand Up @@ -214,60 +256,88 @@ function canCacheResponse (statusCode, headers, cacheControlDirectives) {
}

/**
* @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('../../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)
}

if (cacheControlDirectives.immutable) {
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
return now + 31536000
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
}
}

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
}

/**
* @param {number} now
* @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']) {
staleWhileRevalidate = now + (cacheControlDirectives['stale-while-revalidate'] * 1000)
staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000)
}

if (cacheControlDirectives['stale-if-error']) {
staleIfError = now + (cacheControlDirectives['stale-if-error'] * 1000)
staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000)
}

return Math.max(staleAt, staleWhileRevalidate, staleIfError)
if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) {
immutable = 31536000
}

return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable)
}

/**
Expand All @@ -277,7 +347,29 @@ function determineDeleteAt (now, cacheControlDirectives, staleAt) {
* @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'])) {
headersToRemove.push(...cacheControlDirectives['no-cache'])
Expand All @@ -288,12 +380,13 @@ function stripNecessaryHeaders (headers, cacheControlDirectives) {
}

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
}

Expand Down
Loading

0 comments on commit 0fd1520

Please sign in to comment.