Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions lib/interceptor/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,24 @@ const nop = () => {}

/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
* @param {number} age
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts
* @returns {boolean}
*/
function needsRevalidation (result, cacheControlDirectives, { headers = {} }) {
function needsRevalidation (result, age, cacheControlDirectives, { headers = {} }) {
// Always revalidate requests with the no-cache request directive.
if (cacheControlDirectives?.['no-cache']) {
return true
}

// Revalidate when age >= max-age. max-age=0 is falsy but must still
// force it (e.g. fetch cache: 'no-cache' sends max-age=0).
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
if (cacheControlDirectives?.['max-age'] !== undefined && age >= cacheControlDirectives['max-age']) {
return true
}

// Always revalidate requests with unqualified no-cache response directive.
if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
return true
Expand Down Expand Up @@ -280,14 +288,9 @@ function handleResult (
}

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

const stale = isStale(result, reqCacheControl)
const revalidate = needsRevalidation(result, reqCacheControl, opts)
const revalidate = needsRevalidation(result, age, reqCacheControl, opts)

// Check if the response is stale
if (stale || revalidate) {
Expand Down
182 changes: 182 additions & 0 deletions test/interceptors/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,188 @@ describe('Cache Interceptor', () => {
equal(requestsToOrigin, 2)
})

test('max-age=0 revalidates the response (RFC 9111 §5.2.1.1)', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-none-match']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'public, s-maxage=100')
res.setHeader('etag', '"asd"')
res.end('asd')
}
}).listen(0)

const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())

after(async () => {
server.close()
await client.close()
})

await once(server, 'listening')

strictEqual(requestsToOrigin, 0)

/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}

// Send initial request to fill the cache
{
const response = await client.request(request)
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
strictEqual(await response.body.text(), 'asd')
}

// Send second request with max-age=0, a validation request should be
// sent and the cached response served on the 304
{
const response = await client.request({
...request,
headers: {
'cache-control': 'max-age=0'
}
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
strictEqual(response.statusCode, 200)
strictEqual(await response.body.text(), 'asd')
}

// Send third request w/o max-age, this should be handled by the cache
{
const response = await client.request(request)
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
strictEqual(await response.body.text(), 'asd')
}
})

test('response fetched due to max-age exceedance updates the cache', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})

let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, s-maxage=100')
res.end(`response ${requestsToOrigin}`)
}).listen(0)

const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())

after(async () => {
clock.uninstall()
server.close()
await client.close()
})

await once(server, 'listening')

strictEqual(requestsToOrigin, 0)

/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}

// Send first request to cache the response
{
const response = await client.request(request)
strictEqual(requestsToOrigin, 1)
strictEqual(await response.body.text(), 'response 1')
}

clock.tick(6000)

// Send second request with the response's age exceeding the request's
// max-age, the origin's fresh response should be served and stored
{
const response = await client.request({
...request,
headers: {
'cache-control': 'max-age=5'
}
})
strictEqual(requestsToOrigin, 2)
strictEqual(await response.body.text(), 'response 2')
}

// Send third request w/o max-age, the fresh response stored by the
// previous request should be served from the cache
{
const response = await client.request(request)
strictEqual(requestsToOrigin, 2)
strictEqual(await response.body.text(), 'response 2')
}
})

test('fetch with cache: no-cache revalidates through a composed cache dispatcher', async () => {
const { fetch, Agent } = require('../..')

let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-none-match']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=100')
res.setHeader('etag', '"asd"')
res.end('asd')
}
}).listen(0)

const dispatcher = new Agent().compose(interceptors.cache())

after(async () => {
server.close()
await dispatcher.close()
})

await once(server, 'listening')

const origin = `http://localhost:${server.address().port}`

// Send initial request to fill the cache
{
const response = await fetch(origin, { dispatcher })
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
strictEqual(await response.text(), 'asd')
}

// The no-cache fetch cache mode appends cache-control: max-age=0, which
// must trigger a validation request
{
const response = await fetch(origin, { dispatcher, cache: 'no-cache' })
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
strictEqual(response.status, 200)
strictEqual(await response.text(), 'asd')
}
})

test('max-stale', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
Expand Down
Loading