'use strict'
const { createServer } = require('node:http')
const { describe, test, after } = require('node:test')
const { once } = require('node:events')
const { equal, strictEqual, notEqual, fail } = require('node:assert')
const { setTimeout: sleep } = require('node:timers/promises')
const FakeTimers = require('@sinonjs/fake-timers')
const { Client, interceptors, cacheStores: { MemoryCacheStore, SqliteCacheStore } } = require('../../index')
const { makeCacheKey } = require('../../lib/util/cache.js')
const { runtimeFeatures } = require('../../lib/util/runtime-features.js')
describe('Cache Interceptor', () => {
test('caches request', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
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')
// Sanity check
equal(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
})
// https://github.com/nodejs/undici/issues/5818
test('successful cached response is not aborted', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'max-age=60')
res.end('ok')
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
await (await client.request(request)).body.dump()
const aborted = await new Promise((resolve, reject) => {
client.dispatch(request, {
onRequestStart () {},
onResponseEnd (controller) { resolve(controller.aborted) },
onResponseError (controller, error) { reject(error) }
})
})
strictEqual(aborted, false)
})
test('aborting a cached response reports aborted', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'max-age=60')
res.end('ok')
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
await (await client.request(request)).body.dump()
const { controller, error } = await new Promise((resolve, reject) => {
client.dispatch(request, {
onRequestStart (controller) {
controller.abort()
},
onResponseEnd () { reject(new Error('expected abort')) },
onResponseError (controller, error) { resolve({ controller, error }) }
})
})
strictEqual(controller.aborted, true)
strictEqual(controller.reason, error)
controller.abort(new Error('second abort'))
strictEqual(controller.reason, error)
})
test('aborting a completed cached response preserves the first reason', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=60')
res.end('ok')
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
await (await client.request(request)).body.dump()
const controller = await new Promise((resolve, reject) => {
client.dispatch(request, {
onResponseEnd (controller) { resolve(controller) },
onResponseError (controller, error) { reject(error) }
})
})
strictEqual(requestsToOrigin, 1)
strictEqual(controller.aborted, false)
strictEqual(controller.reason, null)
const reason = new Error('abort after completion')
controller.abort(reason)
strictEqual(controller.aborted, true)
strictEqual(controller.reason, reason)
controller.abort(new Error('second abort'))
strictEqual(controller.reason, reason)
})
test('shared cache does not store responses with Set-Cookie', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
const cookie = req.headers.cookie
res.setHeader('cache-control', 'public, max-age=300')
res.setHeader('set-cookie', `session-for-${cookie}`)
res.end(`response for ${cookie}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: { cookie: 'user-a' }
})
equal(requestsToOrigin, 1)
equal(res.headers['set-cookie'], 'session-for-user-a')
strictEqual(await res.body.text(), 'response for user-a')
}
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: { cookie: 'user-b' }
})
equal(requestsToOrigin, 2)
equal(res.headers['set-cookie'], 'session-for-user-b')
strictEqual(await res.body.text(), 'response for user-b')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
})
test('shared cache does not serve existing entries with Set-Cookie', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
const cookie = req.headers.cookie
res.setHeader('cache-control', 'public, max-age=300')
res.setHeader('set-cookie', `session-for-${cookie}`)
res.end(`response for ${cookie}`)
}).listen(0)
await once(server, 'listening')
const store = new MemoryCacheStore()
const origin = `http://localhost:${server.address().port}`
const privateClient = new Client(origin)
.compose(interceptors.cache({ store, type: 'private' }))
const sharedClient = new Client(origin)
.compose(interceptors.cache({ store, type: 'shared' }))
try {
{
const res = await privateClient.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: { cookie: 'user-a' }
})
equal(requestsToOrigin, 1)
equal(res.headers['set-cookie'], 'session-for-user-a')
strictEqual(await res.body.text(), 'response for user-a')
}
{
const res = await privateClient.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: { cookie: 'user-a' }
})
equal(requestsToOrigin, 1)
equal(res.headers['set-cookie'], 'session-for-user-a')
strictEqual(await res.body.text(), 'response for user-a')
}
{
const res = await sharedClient.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: { cookie: 'user-b' }
})
equal(requestsToOrigin, 2)
equal(res.headers['set-cookie'], 'session-for-user-b')
strictEqual(await res.body.text(), 'response for user-b')
}
} finally {
await privateClient.close()
await sharedClient.close()
await new Promise(resolve => server.close(resolve))
}
})
test('vary directives used to decide which response to use', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.setHeader('vary', 'a')
if (req.headers.a === 'asd123') {
res.end('asd')
} else {
res.end('dsa')
}
}).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')
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const requestA = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
a: 'asd123'
}
}
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const requestB = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
a: 'dsa'
}
}
// Should reach origin
{
const res = await client.request(requestA)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
// Should reach origin
{
const res = await client.request(requestB)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'dsa')
}
// Should be cached
{
const res = await client.request(requestA)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
// Should be cached
{
const res = await client.request(requestB)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'dsa')
}
})
test('stale-while-revalidate 304 preserves cached Vary metadata', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.setHeader('date', new Date().toUTCString())
res.setHeader('cache-control', 'public, max-age=1, stale-while-revalidate=60')
res.setHeader('etag', '"cached"')
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
revalidationRequests++
res.statusCode = 304
res.end()
return
}
requestsToOrigin++
res.setHeader('vary', 'cookie')
res.end(`hit ${requestsToOrigin}: ${req.headers.cookie}`)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
const request = cookie => ({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
cookie
}
})
{
const res = await client.request(request('session=a'))
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
clock.tick(1500)
{
const res = await client.request(request('session=a'))
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
await sleep(100)
equal(revalidationRequests, 1)
{
const res = await client.request(request('session=b'))
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2: session=b')
}
})
test('Vary array-valued request headers are matched by value, not mutable reference', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('vary', 'cookie')
res.end(`hit ${requestsToOrigin}: ${req.headers.cookie}`)
}).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')
const headers = {
cookie: ['session=a']
}
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
headers.cookie[0] = 'session=b'
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2: session=b')
}
headers.cookie = ['session=a']
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
})
test('comma-separated Vary values inside repeated header fields are honored', async () => {
let requestsToOrigin = 0
const server = createServer((req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('vary', ['cookie, authorization', 'user-agent'])
res.end(`hit ${requestsToOrigin}: ${req.headers.cookie}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = cookie => ({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
cookie,
'user-agent': 'cache-test'
}
})
{
const res = await client.request(request('session=a'))
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
{
const res = await client.request(request('session=b'))
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2: session=b')
}
{
const res = await client.request(request('session=a'))
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
})
test('does not cache responses with wildcard Vary in repeated header fields', async () => {
let requestsToOrigin = 0
const server = createServer((_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('vary', ['accept-encoding, *', 'user-agent'])
res.end(`hit ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'user-agent': 'cache-test'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
})
test('does not cache responses with malformed Vary field names', async () => {
let requestsToOrigin = 0
const server = createServer((req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('vary', 'cookie authorization')
res.end(`hit ${requestsToOrigin}: ${req.headers.cookie}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = cookie => ({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
cookie
}
})
{
const res = await client.request(request('session=a'))
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1: session=a')
}
{
const res = await client.request(request('session=b'))
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2: session=b')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
})
test('revalidates responses with no-cache directive, regardless of cacheByDefault', async () => {
let requestCount = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
++requestCount
res.setHeader('Vary', 'Accept-Encoding')
res.setHeader('cache-control', 'no-cache')
res.end(`Request count: ${requestCount}`)
}).listen(0)
after(async () => {
server.close()
await once(server, 'close')
})
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
cacheByDefault: 1000
}))
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
const res1 = await client.request(request)
const body1 = await res1.body.text()
strictEqual(body1, 'Request count: 1')
strictEqual(requestCount, 1)
const res2 = await client.request(request)
const body2 = await res2.body.text()
strictEqual(body2, 'Request count: 2')
strictEqual(requestCount, 2)
})
test('stores response with no-cache directive and etag, revalidates it on reuse', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-none-match'] === '"asd123"') {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'no-cache')
res.setHeader('etag', '"asd123"')
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')
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
}
// Send second request. The response was stored, so this should be a
// revalidation request answered with a 304 and served from the cache
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
}
})
test('stores response with max-age=0 and etag, revalidates it on reuse', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-none-match'] === '"asd123"') {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=0')
res.setHeader('etag', '"asd123"')
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')
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
}
// Send second request. The response was stored but is already stale, so
// this should be a revalidation request answered with a 304 and served
// from the cache
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
}
})
test('stores response with max-age=0 and etag, revalidates it on reuse within the same millisecond', async () => {
// Freeze the clock on a whole second so the Date header carries no apparent
// age and both requests observe now === staleAt
const clock = FakeTimers.install({
now: 1000,
toFake: ['Date']
})
after(() => clock.uninstall())
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.sendDate = false
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] === '"asd123"') {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=0')
res.setHeader('etag', '"asd123"')
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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
}
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
}
})
test('stores response with no-cache directive, etag and last-modified, revalidates it on reuse', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-none-match'] === '"asd123"') {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'no-cache')
res.setHeader('etag', '"asd123"')
res.setHeader('last-modified', new Date(Date.now() - 60000).toUTCString())
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')
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
}
// Send second request. The response was stored, so this should be a
// revalidation request answered with a 304 and served from the cache
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'asd')
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
}
})
test('expires caching', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let serverError
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
const now = new Date()
now.setSeconds(now.getSeconds() + 1)
res.setHeader('date', 0)
res.setHeader('expires', now.toGMTString())
requestsToOrigin++
res.end('asd')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
if (serverError) {
throw serverError
}
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
// This is cached
{
const res = await client.request(request)
if (serverError) {
throw serverError
}
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
clock.tick(1500)
// Response is now stale, the origin should get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
// Response is now cached, the origin should not get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
})
test('expires caching with Etag', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let serverError
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
const now = new Date()
now.setSeconds(now.getSeconds() + 1)
res.setHeader('date', 0)
res.setHeader('expires', now.toGMTString())
res.setHeader('etag', 'asd123')
requestsToOrigin++
res.end('asd')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
if (serverError) {
throw serverError
}
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
// This is cached
{
const res = await client.request(request)
if (serverError) {
throw serverError
}
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
clock.tick(1500)
// Response is now stale, the origin should get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
// Response is now cached, the origin should not get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
})
test('max-age caching', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let serverError
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.setHeader('date', 0)
res.setHeader('cache-control', 's-maxage=1')
requestsToOrigin++
res.end('asd')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request. This should reach the origin
{
const res = await client.request(request)
if (serverError) {
throw serverError
}
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'asd')
}
clock.tick(1500)
// Response is now stale, the origin should get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
// Response is now cached, the origin should not get a request
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'asd')
}
})
test('vary headers are present in revalidation request', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let revalidationRequests = 0
let serverError
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.setHeader('date', 0)
try {
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch) {
revalidationRequests++
notEqual(req.headers.a, undefined)
notEqual(req.headers['b-mixed-case'], undefined)
res.statusCode = 304
res.setHeader('vary', 'a, B-MIXED-CASe')
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=1')
res.setHeader('vary', 'a, B-MIXED-CASe')
res.setHeader('etag', '"asd"')
res.end('asd')
}
} catch (err) {
serverError = err
res.end()
}
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
strictEqual(revalidationRequests, 0)
const request = {
origin: 'localhost',
path: '/',
method: 'GET'
}
{
const response = await client.request({
...request,
headers: {
a: 'asd',
'b-Mixed-case': 'asd'
}
})
if (serverError) {
throw serverError
}
strictEqual(requestsToOrigin, 1)
strictEqual(await response.body.text(), 'asd')
}
clock.tick(1500)
{
const response = await client.request({
...request,
headers: {
a: 'asd',
'B-mixed-CASE': 'asd'
}
})
if (serverError) {
throw serverError
}
strictEqual(requestsToOrigin, 1)
strictEqual(await response.body.text(), 'asd')
}
strictEqual(revalidationRequests, 1)
})
test('unsafe methods cause resource to be purged from cache', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => res.end('asd')).listen(0)
after(() => server.close())
await once(server, 'listening')
const store = new MemoryCacheStore()
let deleteCalled = false
const originalDelete = store.delete.bind(store)
store.delete = (key) => {
deleteCalled = true
originalDelete(key)
}
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
store,
methods: ['GET'] // explicitly only cache GET methods
}))
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request, will cache the response
await client.request(request)
// Sanity check
equal(deleteCalled, false)
// Make sure the common unsafe methods cause cache purges
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
deleteCalled = false
await client.request({
...request,
method
})
equal(deleteCalled, true, method)
}
})
test('unsafe methods purge same-origin Location and Content-Location targets from cache', async () => {
let locationTargetRequests = 0
let contentLocationTargetRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.method === 'GET' && req.url === '/location-target') {
locationTargetRequests++
res.setHeader('cache-control', 'public, max-age=60')
res.end(`location ${locationTargetRequests}`)
return
}
if (req.method === 'GET' && req.url === '/content-location-target') {
contentLocationTargetRequests++
res.setHeader('cache-control', 'public, max-age=60')
res.end(`content-location ${contentLocationTargetRequests}`)
return
}
if (req.method === 'POST' && req.url === '/mutate') {
res.statusCode = 204
res.setHeader('location', '/location-target')
res.setHeader('content-location', '/content-location-target')
res.end()
return
}
res.statusCode = 404
res.end()
}).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')
const origin = `http://localhost:${server.address().port}`
{
const res = await client.request({ origin, method: 'GET', path: '/location-target' })
strictEqual(await res.body.text(), 'location 1')
}
{
const res = await client.request({ origin, method: 'GET', path: '/content-location-target' })
strictEqual(await res.body.text(), 'content-location 1')
}
{
const res = await client.request({ origin, method: 'POST', path: '/mutate' })
strictEqual(res.statusCode, 204)
await res.body.text()
}
{
const res = await client.request({ origin, method: 'GET', path: '/location-target' })
strictEqual(await res.body.text(), 'location 2')
}
{
const res = await client.request({ origin, method: 'GET', path: '/content-location-target' })
strictEqual(await res.body.text(), 'content-location 2')
}
})
test('unsafe methods do not purge cross-origin Location and Content-Location targets from cache', async () => {
let targetRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.method === 'GET' && req.url === '/target') {
targetRequests++
res.setHeader('cache-control', 'public, max-age=60')
res.end(`target ${targetRequests}`)
return
}
if (req.method === 'POST' && req.url === '/mutate') {
res.statusCode = 204
res.setHeader('location', 'http://example.com/target')
res.setHeader('content-location', 'http://example.com/target')
res.end()
return
}
res.statusCode = 404
res.end()
}).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')
const origin = `http://localhost:${server.address().port}`
{
const res = await client.request({ origin, method: 'GET', path: '/target' })
strictEqual(await res.body.text(), 'target 1')
}
{
const res = await client.request({ origin, method: 'POST', path: '/mutate' })
strictEqual(res.statusCode, 204)
await res.body.text()
}
{
const res = await client.request({ origin, method: 'GET', path: '/target' })
strictEqual(await res.body.text(), 'target 1')
}
})
test('unsafe methods aren\'t cached', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, s-maxage=1')
res.end('')
}).listen(0)
after(() => server.close())
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
store: {
get () {
return undefined
},
createWriteStream (key) {
fail(key.method)
},
delete () { }
}
}))
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
await client.request({
origin: 'localhost',
method,
path: '/'
})
}
})
test('unsafe methods are not served from cache', async () => {
// A heuristically-cacheable response (404) with an explicit max-age would
// previously be stored under the unsafe method and replayed on a second,
// identical state-changing request without ever hitting the origin.
let requestsToOrigin = 0
const server = createServer((req, res) => {
requestsToOrigin++
res.statusCode = 404
res.setHeader('cache-control', 'max-age=60')
res.end(`origin-hit-#${requestsToOrigin}`)
}).listen(0)
after(() => server.close())
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
store: new MemoryCacheStore()
}))
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
const req = { origin: 'localhost', method, path: '/resource/123' }
const before = requestsToOrigin
const res1 = await client.request(req)
const body1 = await res1.body.text()
const res2 = await client.request(req)
const body2 = await res2.body.text()
// Every unsafe-method request must reach the origin; none may be served from cache.
equal(requestsToOrigin - before, 2)
equal(body1, `origin-hit-#${before + 1}`)
equal(body2, `origin-hit-#${before + 2}`)
}
await client.close()
})
test('successful unsafe methods still invalidate cached entries', async () => {
// The fix routes unsafe methods through CacheHandler (not a bare dispatch) so a
// successful unsafe request keeps invalidating an existing cached GET for the same path.
let requestsToOrigin = 0
const server = createServer((req, res) => {
requestsToOrigin++
if (req.method === 'GET') {
res.statusCode = 200
res.setHeader('cache-control', 'max-age=60')
} else {
res.statusCode = 200
}
res.end(`origin-hit-#${requestsToOrigin}`)
}).listen(0)
after(() => server.close())
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
store: new MemoryCacheStore()
}))
const get = { origin: 'localhost', method: 'GET', path: '/resource/123' }
await (await client.request(get)).body.text()
const afterPrime = requestsToOrigin
await (await client.request(get)).body.text()
equal(requestsToOrigin, afterPrime) // second GET served from cache
await (await client.request({ origin: 'localhost', method: 'POST', path: '/resource/123' })).body.text()
const beforeFinalGet = requestsToOrigin
await (await client.request(get)).body.text()
equal(requestsToOrigin - beforeFinalGet, 1) // GET re-fetched after invalidation
await client.close()
})
test('necessary headers are stripped', async () => {
const headers = [
// Headers defined in the spec that we need to strip
'connection',
'proxy-authenticate',
'proxy-authentication-info',
'proxy-authorization',
'proxy-connection',
'te',
'upgrade',
// Headers we need to specify to be stripped
'should-be-stripped'
]
let requestToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestToOrigin++
res.setHeader('cache-control', 's-maxage=10, no-cache=should-be-stripped')
res.setHeader('should-not-be-stripped', 'asd')
for (const header of headers) {
res.setHeader(header, 'asd')
}
res.end()
}).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')
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestToOrigin, 1)
equal(res.headers['should-not-be-stripped'], 'asd')
for (const header of headers) {
equal(res.headers[header], 'asd')
}
}
{
const res = await client.request(request)
equal(requestToOrigin, 1)
equal(res.headers['should-not-be-stripped'], 'asd')
equal(res.headers['transfer-encoding'], undefined)
for (const header of headers) {
equal(res.headers[header], undefined)
}
}
})
test('qualified no-cache/private with OWS around = strip named headers from cached responses', async () => {
for (const cacheControl of [
'public, max-age=60, no-cache= "x-secret"',
'public, max-age=60, no-cache ="x-secret"',
'public, max-age=60, no-cache \t= \t"x-secret"',
'public, max-age=60, no-cache="x-secret"\t, immutable',
'public, max-age=60, private= "x-secret"',
'public, max-age=60, private ="x-secret"',
'public, max-age=60, private\t=\t"x-secret"',
'public, max-age=60, private="x-secret"\t, immutable'
]) {
let requestToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestToOrigin++
res.setHeader('cache-control', cacheControl)
res.setHeader('x-secret', 'secret')
res.end('ok')
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestToOrigin, 1, cacheControl)
equal(res.headers['x-secret'], 'secret', cacheControl)
strictEqual(await res.body.text(), 'ok')
}
{
const res = await client.request(request)
equal(requestToOrigin, 1, cacheControl)
equal(res.headers['x-secret'], undefined, cacheControl)
strictEqual(await res.body.text(), 'ok')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('connection-nominated headers are stripped case-insensitively', async () => {
let requestToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.setHeader('connection', ['X-Other-Secret, X-Secret', 'Keep-Alive, X-Empty'])
res.setHeader('x-other-secret', 'other secret')
res.setHeader('x-secret', 'secret')
res.setHeader('x-empty', '')
res.end('ok')
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestToOrigin, 1)
equal(res.headers['x-other-secret'], 'other secret')
equal(res.headers['x-secret'], 'secret')
equal(res.headers['x-empty'], '')
strictEqual(await res.body.text(), 'ok')
}
{
const res = await client.request(request)
equal(requestToOrigin, 1)
equal(res.headers['x-other-secret'], undefined)
equal(res.headers['x-secret'], undefined)
equal(res.headers['x-empty'], undefined)
strictEqual(await res.body.text(), 'ok')
}
})
test('malformed quoted no-cache does not swallow a following no-store directive', async () => {
for (const cacheControl of [
'public, max-age=60, no-cache="set-cookie" garbage, no-store',
'public, max-age=60, no-cache="set-cookie, no-store',
'public, max-age=60, no-cache=',
'public, max-age=60, no-store\u00a0',
'public, max-age=60, no\u00a0-store'
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', cacheControl)
res.end(`hit ${requestsToOrigin}`)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, cacheControl)
strictEqual(await res.body.text(), 'hit 1')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2, cacheControl)
strictEqual(await res.body.text(), 'hit 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('cacheByDefault', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.end('asd')
}).listen(0)
after(() => server.close())
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
cacheByDefault: 3600
}))
equal(requestsToOrigin, 0)
// Should hit the origin
{
const res = await client.request({
origin: 'localhost',
path: '/',
method: 'GET'
})
equal(requestsToOrigin, 1)
equal(await res.body.text(), 'asd')
}
// Should hit the cache
{
const res = await client.request({
origin: 'localhost',
path: '/',
method: 'GET'
})
equal(requestsToOrigin, 1)
equal(await res.body.text(), 'asd')
}
})
test('malformed Last-Modified does not enable heuristic caching', async () => {
for (const lastModified of ['0', '2025-01-01']) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('last-modified', lastModified)
res.end(`hit ${requestsToOrigin}`)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, lastModified)
equal(await res.body.text(), 'hit 1', lastModified)
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2, lastModified)
equal(await res.body.text(), 'hit 2', lastModified)
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('cacheByDefault does not make explicitly stale responses fresh', async () => {
const clock = FakeTimers.install({
now: new Date('2026-01-01T00:00:00.000Z'),
toFake: ['Date']
})
try {
for (const { name, headers } of [
{
name: 'max-age=0',
headers: {
'cache-control': 'public, max-age=0'
}
},
{
name: 's-maxage=0',
headers: {
'cache-control': 'public, s-maxage=0'
}
},
{
name: 'Expires in the past',
headers: {
expires: new Date(clock.now - 1000).toUTCString()
}
},
{
name: 'invalid Expires',
headers: {
expires: 'not a date'
}
},
{
name: 'invalid Expires day overflow',
headers: {
date: new Date(clock.now).toUTCString(),
expires: 'Sun, 32 Jan 2026 00:00:00 GMT'
}
},
{
name: 'Age greater than Expires lifetime',
headers: {
date: new Date(clock.now).toUTCString(),
expires: new Date(clock.now + 1000).toUTCString(),
age: '2'
}
},
{
name: 'malformed Age',
headers: {
'cache-control': 'public, max-age=60',
age: '0x60'
}
},
{
name: 'duplicate Age',
headers: {
'cache-control': 'public, max-age=60',
age: ['0', '9999999999']
}
},
{
name: 'duplicate Date',
headers: {
'cache-control': 'public, max-age=60',
date: [
new Date(clock.now - 120000).toUTCString(),
new Date(clock.now).toUTCString()
]
}
},
{
name: 'invalid max-age',
headers: {
'cache-control': 'public, max-age=bad'
}
},
{
name: 'invalid duplicate max-age',
headers: {
'cache-control': 'public, max-age=60, max-age=bad'
}
},
{
name: 'invalid max-age overrides Expires',
headers: {
'cache-control': 'public, max-age=bad',
expires: new Date(clock.now + 60000).toUTCString()
}
},
{
name: 'invalid s-maxage',
headers: {
'cache-control': 'public, s-maxage=bad'
}
},
{
name: 'invalid s-maxage overrides max-age in shared cache',
headers: {
'cache-control': 'public, s-maxage=bad, max-age=60'
}
}
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value)
}
res.end(`hit ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
cacheByDefault: 3600
}))
try {
const request = {
origin: 'localhost',
path: '/',
method: 'GET'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, name)
equal(await res.body.text(), 'hit 1')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2, name)
equal(await res.body.text(), 'hit 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
} finally {
clock.uninstall()
}
})
test('304 revalidation metadata that forbids reuse is not reused from cache', async () => {
for (const testCase of [
{ name: 'no-store', headers: { 'cache-control': 'no-store' } },
{ name: 'private', headers: { 'cache-control': 'private' } },
{ name: 'set-cookie', headers: { 'set-cookie': 'session=secret' } },
{ name: 'vary-star', headers: { vary: '*' } },
{ name: 'malformed-vary', headers: { vary: 'cookie authorization' } }
]) {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let conditionalRequests = 0
let unconditionalRefetches = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
conditionalRequests++
res.statusCode = 304
for (const [headerName, headerValue] of Object.entries(testCase.headers)) {
res.setHeader(headerName, headerValue)
}
res.end()
return
}
if (requestsToOrigin === 1) {
res.setHeader('cache-control', 'public, max-age=1')
res.setHeader('etag', '"cached"')
res.end('cached')
return
}
unconditionalRefetches++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('etag', '"refetched"')
res.end('refetched')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, testCase.name)
strictEqual(await res.body.text(), 'cached', testCase.name)
}
clock.tick(1500)
{
const res = await client.request(request)
equal(conditionalRequests, 1, testCase.name)
equal(unconditionalRefetches, 1, testCase.name)
strictEqual(await res.body.text(), 'refetched', testCase.name)
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
clock.uninstall()
}
}
})
test('304 synchronous revalidation refetches when Vary adds a request header', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let conditionalRequests = 0
let unconditionalRefetches = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
conditionalRequests++
res.statusCode = 304
res.setHeader('vary', 'x-variant')
res.end()
return
}
if (requestsToOrigin === 1) {
res.setHeader('cache-control', 'public, max-age=1')
res.setHeader('etag', '"cached"')
res.end('cached')
return
}
unconditionalRefetches++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('etag', `"variant-${req.headers['x-variant']}"`)
res.setHeader('vary', 'x-variant')
res.end(`variant ${req.headers['x-variant']}`)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = variant => ({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'x-variant': variant
}
})
{
const res = await client.request(request('a'))
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
clock.tick(1500)
{
const res = await client.request(request('b'))
equal(conditionalRequests, 1)
equal(unconditionalRefetches, 1)
strictEqual(await res.body.text(), 'variant b')
}
{
const res = await client.request(request('c'))
equal(unconditionalRefetches, 2)
strictEqual(await res.body.text(), 'variant c')
}
{
const res = await client.request(request('b'))
equal(requestsToOrigin, 4)
strictEqual(await res.body.text(), 'variant b')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
clock.uninstall()
}
})
test('304 synchronous revalidation Cache-Control metadata evicts stale-while-revalidate entry', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let conditionalRequests = 0
let unconditionalRefetches = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
conditionalRequests++
res.statusCode = 304
res.setHeader('cache-control', 'public, no-cache')
res.end()
return
}
if (requestsToOrigin === 1) {
res.setHeader('cache-control', 'public, max-age=1, stale-while-revalidate=10')
res.setHeader('etag', '"cached"')
res.end('cached')
return
}
unconditionalRefetches++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('etag', '"refetched"')
res.end('refetched')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
clock.tick(1500)
{
const res = await client.request({
...request,
headers: {
'cache-control': 'no-cache'
}
})
equal(conditionalRequests, 1)
strictEqual(await res.body.text(), 'cached')
}
{
const res = await client.request(request)
equal(unconditionalRefetches, 1)
strictEqual(await res.body.text(), 'refetched')
}
})
test('304 stale-while-revalidate metadata that forbids reuse evicts the cached response', async () => {
for (const testCase of [
{ name: 'no-store', headers: { 'cache-control': 'no-store' } },
{ name: 'private', headers: { 'cache-control': 'private' } },
{ name: 'set-cookie', headers: { 'set-cookie': 'session=secret' } },
{ name: 'vary-star', headers: { vary: '*' } },
{ name: 'malformed-vary', headers: { vary: 'cookie authorization' } },
{ name: 'explicitly-stale', headers: { 'cache-control': 'public, max-age=0' } },
{ name: 'no-cache', headers: { 'cache-control': 'public, no-cache' } },
{ name: 'must-revalidate', headers: { 'cache-control': 'public, must-revalidate' } }
]) {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let conditionalRequests = 0
let unconditionalRefetches = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
conditionalRequests++
res.statusCode = 304
for (const [headerName, headerValue] of Object.entries(testCase.headers)) {
res.setHeader(headerName, headerValue)
}
res.end()
return
}
if (requestsToOrigin === 1) {
res.setHeader('cache-control', 'public, max-age=1, stale-while-revalidate=10')
res.setHeader('etag', '"cached"')
res.end('cached')
return
}
unconditionalRefetches++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('etag', '"refetched"')
res.end('refetched')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, testCase.name)
strictEqual(await res.body.text(), 'cached', testCase.name)
}
clock.tick(1500)
{
const res = await client.request(request)
equal(requestsToOrigin, 1, testCase.name)
strictEqual(await res.body.text(), 'cached', testCase.name)
}
await sleep(100)
equal(conditionalRequests, 1, testCase.name)
{
const res = await client.request(request)
equal(unconditionalRefetches, 1, testCase.name)
strictEqual(await res.body.text(), 'refetched', testCase.name)
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
clock.uninstall()
}
}
})
test('stale-while-revalidate evicts a shared entry when its replacement has Set-Cookie', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let conditionalRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
if (req.headers['if-none-match'] || req.headers['if-modified-since']) {
conditionalRequests++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('set-cookie', 'session=secret')
res.end('personalized')
return
}
if (requestsToOrigin === 1) {
res.setHeader('cache-control', 'public, max-age=1, stale-while-revalidate=10')
res.setHeader('etag', '"cached"')
res.end('cached')
return
}
res.setHeader('cache-control', 'public, max-age=60')
res.end('refetched')
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'cached')
}
clock.tick(1500)
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'cached')
}
await sleep(100)
equal(conditionalRequests, 1)
{
const res = await client.request(request)
equal(requestsToOrigin, 3)
strictEqual(await res.body.text(), 'refetched')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
clock.uninstall()
}
})
test('stale-if-error (response)', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('date', 0)
requestsToOrigin++
if (requestsToOrigin === 1) {
// First request
res.setHeader('cache-control', 'public, max-age=10, stale-if-error=20')
res.end('asd')
} else {
res.statusCode = 500
res.end('')
}
}).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. This will hit the origin and succeed
{
const response = await client.request(request)
equal(requestsToOrigin, 1)
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
// Send second request. It isn't stale yet, so this should be from the
// cache and succeed
{
const response = await client.request(request)
equal(requestsToOrigin, 1)
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
clock.tick(15000)
// Send third request. This is now stale, the revalidation request should
// fail but the response should still be served from cache.
{
const response = await client.request(request)
equal(requestsToOrigin, 2)
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
clock.tick(25000)
// Send fourth request. We're now outside the stale-if-error threshold and
// should see the error.
{
const response = await client.request(request)
equal(requestsToOrigin, 3)
equal(response.statusCode, 500)
}
})
describe('Client-side directives', () => {
test('max-age', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('date', 0)
res.setHeader('cache-control', 'public, s-maxage=100')
res.end()
}).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
await client.request(request)
equal(requestsToOrigin, 1)
// Send second request with max-age=0, should force the request to reach
// the origin even immediately after insertion.
await client.request({
...request,
headers: {
'cache-control': 'max-age=0'
}
})
equal(requestsToOrigin, 2)
// Send third request, should be served by the cache since it's within
// the window
await client.request({
...request,
headers: {
'cache-control': 'max-age=5'
}
})
equal(requestsToOrigin, 2)
clock.tick(6000)
// Send fourth request, should reach the origin
await client.request({
...request,
headers: {
'cache-control': 'max-age=5'
}
})
equal(requestsToOrigin, 3)
})
test('max-age=0 revalidates cached response conditionally', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('etag', '"cached"')
if (req.headers['if-none-match']) {
revalidationRequests++
equal(req.headers['cache-control'], 'max-age=0')
res.statusCode = 304
res.end()
return
}
res.end(`hit ${requestsToOrigin}`)
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1')
}
{
const res = await client.request({
...request,
headers: {
'cache-control': 'max-age=0'
}
})
equal(requestsToOrigin, 2)
equal(revalidationRequests, 1)
strictEqual(await res.body.text(), 'hit 1')
}
})
test('no-cache revalidation uses stored Last-Modified as If-Modified-Since', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const lastModified = 'Wed, 01 Jan 2020 00:00:00 GMT'
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', new Date().toUTCString())
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('last-modified', lastModified)
if (req.headers['if-modified-since']) {
revalidationRequests++
if (req.headers['if-modified-since'] === lastModified) {
res.statusCode = 304
res.end()
return
}
}
res.end(`hit ${requestsToOrigin}`)
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1')
}
{
const res = await client.request({
...request,
headers: {
'cache-control': 'no-cache'
}
})
equal(requestsToOrigin, 2)
equal(revalidationRequests, 1)
strictEqual(await res.body.text(), 'hit 1')
}
})
test('max-age accounts for apparent response age from Date', async () => {
const clock = FakeTimers.install({ now: 100000, toFake: ['Date'] })
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('date', new Date(clock.now - 30000).toUTCString())
res.sendDate = false
res.setHeader('cache-control', 'public, max-age=60')
res.end(`hit ${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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'hit 1')
}
{
const res = await client.request({
...request,
headers: {
'cache-control': 'max-age=10'
}
})
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'hit 2')
}
})
test('max-stale', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.setHeader('date', 0)
res.setHeader('cache-control', 'public, max-age=1, stale-while-revalidate=10')
if (req.headers['if-modified-since']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.end('asd')
}
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
await client.request(request)
equal(requestsToOrigin, 1)
equal(revalidationRequests, 0)
clock.tick(1500)
// Send second request within the max-stale threshold
await client.request({
...request,
headers: {
'cache-control': 'max-stale=5'
}
})
equal(requestsToOrigin, 1)
equal(revalidationRequests, 0)
// Send third request outside the max-stale threshold
await client.request({
...request,
headers: {
'cache-control': 'max-stale=0'
}
})
equal(requestsToOrigin, 1)
// Wait for background revalidation to complete
await sleep(100)
equal(revalidationRequests, 1)
})
test('max-stale does not override response revalidation requirements', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
try {
for (const directive of ['must-revalidate', 'proxy-revalidate', 's-maxage=1']) {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.setHeader('date', 0)
res.setHeader('cache-control', `public, max-age=1, ${directive}`)
if (req.headers['if-modified-since']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.end(`hit ${requestsToOrigin}`)
}
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, directive)
equal(revalidationRequests, 0, directive)
strictEqual(await res.body.text(), 'hit 1')
}
clock.tick(1500)
{
const res = await client.request({
...request,
headers: {
'cache-control': 'max-stale=60'
}
})
equal(requestsToOrigin, 1, directive)
equal(revalidationRequests, 1, directive)
strictEqual(await res.body.text(), 'hit 1')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
} finally {
clock.uninstall()
}
})
test('stale-while-revalidate does not override response revalidation requirements', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
try {
for (const directive of ['must-revalidate', 'proxy-revalidate', 's-maxage=1']) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('date', 0)
res.setHeader('cache-control', `public, max-age=1, stale-while-revalidate=60, ${directive}`)
res.end(`hit ${requestsToOrigin}${req.headers['if-modified-since'] ? ' revalidated' : ''}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, directive)
strictEqual(await res.body.text(), 'hit 1')
}
clock.tick(1500)
{
const res = await client.request(request)
equal(requestsToOrigin, 2, directive)
strictEqual(await res.body.text(), 'hit 2 revalidated')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
} finally {
clock.uninstall()
}
})
test('stale-if-error does not override response revalidation requirements', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
try {
for (const directive of ['must-revalidate', 'proxy-revalidate', 's-maxage=1']) {
for (const staleIfErrorSource of ['response', 'request']) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('date', 0)
if (requestsToOrigin === 1) {
res.setHeader('cache-control', `public, max-age=1, ${directive}${staleIfErrorSource === 'response' ? ', stale-if-error=60' : ''}`)
res.end('cached')
} else {
res.statusCode = 500
res.end('error')
}
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, `${directive} ${staleIfErrorSource}`)
equal(res.statusCode, 200, `${directive} ${staleIfErrorSource}`)
strictEqual(await res.body.text(), 'cached')
}
clock.tick(1500)
{
const res = await client.request({
...request,
headers: staleIfErrorSource === 'request'
? { 'cache-control': 'stale-if-error=60' }
: undefined
})
equal(requestsToOrigin, 2, `${directive} ${staleIfErrorSource}`)
equal(res.statusCode, 500, `${directive} ${staleIfErrorSource}`)
strictEqual(await res.body.text(), 'error')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
}
} finally {
clock.uninstall()
}
})
test('min-fresh', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('date', 0)
res.setHeader('cache-control', 'public, s-maxage=10')
res.end()
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(async () => {
server.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
strictEqual(requestsToOrigin, 0)
/**
* @type {import('../../types/dispatcher').default.RequestOptions}
*/
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
await client.request(request)
equal(requestsToOrigin, 1)
// Fast forward to response having 8sec ttl
clock.tick(2000)
// Send request within the threshold
await client.request({
...request,
headers: {
'cache-control': 'min-fresh=5'
}
})
equal(requestsToOrigin, 1)
// Fast forward again, response has 2sec ttl
clock.tick(6000)
await client.request({
...request,
headers: {
'cache-control': 'min-fresh=5'
}
})
equal(requestsToOrigin, 2)
})
test('no-cache', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-modified-since']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'public, s-maxage=100')
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)
// Send initial request. This should reach the origin
await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'cache-control': 'no-cache'
}
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
// Send second request, a validation request should be sent
await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'cache-control': 'no-cache'
}
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
// Send third request w/o no-cache, this should be handled by the cache
await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
})
test('Pragma no-cache revalidates when Cache-Control is absent', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
if (req.headers['if-modified-since']) {
revalidationRequests++
res.statusCode = 304
res.end()
} else {
requestsToOrigin++
res.setHeader('cache-control', 'public, s-maxage=100')
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')
await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 0)
await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
pragma: 'other, no-cache'
}
})
strictEqual(requestsToOrigin, 1)
strictEqual(revalidationRequests, 1)
})
test('no-store', async () => {
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, s-maxage=100')
res.end('asd')
}).listen(0)
const store = new MemoryCacheStore()
store.createWriteStream = () => {
fail('shouldn\'t have reached this')
}
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({ store }))
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'cache-control': 'no-store'
}
})
})
test('no-store in repeated request Cache-Control fields prevents storing', async () => {
for (const { name, headers } of [
{
name: 'iterable',
headers: [
['cache-control', 'no-store'],
['cache-control', 'max-age=60']
]
},
{
name: 'plain object with different casing',
headers: {
'Cache-Control': 'no-store',
'cache-control': 'max-age=60'
}
}
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.end(`hit ${requestsToOrigin}`)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
await once(server, 'listening')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
{
const res = await client.request({
...request,
headers
})
equal(requestsToOrigin, 1, name)
strictEqual(await res.body.text(), 'hit 1', name)
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2, name)
strictEqual(await res.body.text(), 'hit 2', name)
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('only-if-cached', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, s-maxage=100')
res.end('asd')
requestsToOrigin++
}).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')
// Send initial request. This should reach the origin
await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 1)
// Send second request, this shouldn't reach the origin
await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'cache-control': 'only-if-cached'
}
})
equal(requestsToOrigin, 1)
// Send third request to an uncached resource, this should return a 504
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/bla',
headers: {
'cache-control': 'only-if-cached'
}
})
equal(res.statusCode, 504)
}
// Send fourth request to an uncached resource w/ a , this should return a 504
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/asd123',
headers: {
'cache-control': 'only-if-cached'
}
})
equal(res.statusCode, 504)
}
})
test('stale-if-error', async () => {
const clock = FakeTimers.install({
toFake: ['Date']
})
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('date', 0)
requestsToOrigin++
if (requestsToOrigin === 1) {
// First request, send stale-while-revalidate to keep the value in the cache
res.setHeader('cache-control', 'public, max-age=10, stale-while-revalidate=20')
res.end('asd')
} else {
res.statusCode = 500
res.end('')
}
}).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)
// Send first request. This will hit the origin and succeed
{
const response = await client.request({
origin: 'localhost',
path: '/',
method: 'GET'
})
equal(requestsToOrigin, 1)
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
// Send second request. It isn't stale yet, so this should be from the
// cache and succeed
{
const response = await client.request({
origin: 'localhost',
path: '/',
method: 'GET'
})
equal(requestsToOrigin, 1)
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
clock.tick(15000)
// Send third request. This is now stale but within stale-while-revalidate,
// should return stale immediately and trigger background revalidation
{
const response = await client.request({
origin: 'localhost',
path: '/',
method: 'GET',
headers: {
'cache-control': 'stale-if-error=20'
}
})
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
// Wait for background revalidation to complete (which will fail with 500)
await sleep(100)
equal(requestsToOrigin, 2)
// Send a fourth request. Still within stale-while-revalidate but without stale-if-error,
// should return stale since previous revalidation failed and stale-if-error applies
{
const response = await client.request({
origin: 'localhost',
path: '/',
method: 'GET'
})
equal(response.statusCode, 200)
equal(await response.body.text(), 'asd')
}
// Wait for another background revalidation
await sleep(100)
equal(requestsToOrigin, 3)
clock.tick(25000)
// Send fifth request. We're now outside the stale-if-error threshold and
// should see the error.
{
const response = await client.request({
origin: 'localhost',
path: '/',
method: 'GET',
headers: {
'cache-control': 'stale-if-error=20'
}
})
equal(requestsToOrigin, 4)
equal(response.statusCode, 500)
}
})
})
// Partial list.
const cacheableStatusCodes = [
{ code: 204, body: '' },
{ code: 302, body: 'Found' },
{ code: 307, body: 'Temporary Redirect' },
{ code: 404, body: 'Not Found' },
{ code: 410, body: 'Gone' }
]
for (const { code, body } of cacheableStatusCodes) {
test(`caches ${code} response with cache headers`, async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.statusCode = code
res.setHeader('cache-control', 'public, max-age=60')
res.end(body)
}).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')
equal(requestsToOrigin, 0)
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit the origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
// Second request should be served from cache
{
const res = await client.request(request)
equal(requestsToOrigin, 1) // Should still be 1 (cached)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
})
}
// Partial list.
const nonHeuristicallyCacheableStatusCodes = [
{ code: 201, body: 'Created' },
{ code: 307, body: 'Temporary Redirect' },
{ code: 418, body: 'I am a teapot' }
]
for (const { code, body } of nonHeuristicallyCacheableStatusCodes) {
test(`does not cache non-heuristically cacheable status ${code} without explicit directive`, async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.statusCode = code
// By default the response may have a date and last-modified header set to 'now',
// causing the cache to compute a 0 heuristic expiry, causing the test to not ascertain
// it is really not cached.
res.setHeader('date', '')
res.end(body)
}).listen(0)
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({ cacheByDefault: 60 }))
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
equal(requestsToOrigin, 0)
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit the origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
// Second request should also hit the origin (not cached)
{
const res = await client.request(request)
equal(requestsToOrigin, 2) // Should be 2 (not cached)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
})
}
test('discriminates caching of range requests, or does not cache them', async () => {
let requestsToOrigin = 0
const body = 'Fake range request response'
const code = 206
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.statusCode = code
res.setHeader('cache-control', 'public, max-age=60')
res.end(body)
}).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')
equal(requestsToOrigin, 0)
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
range: 'bytes=10-'
}
}
// First request should hit the origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
// Second request with different range should hit the origin too
request.headers.range = 'bytes=5-'
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
})
test('discriminates caching of conditional requests (if-none-match), or does not cache them', async () => {
let requestsToOrigin = 0
const body = ''
const code = 304
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.statusCode = code
res.setHeader('cache-control', 'public, max-age=60')
res.end(body)
}).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')
equal(requestsToOrigin, 0)
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'if-none-match': 'some-etag'
}
}
// First request should hit the origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
// Second request with different etag should hit the origin too
request.headers['if-none-match'] = 'another-etag'
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
})
test('discriminates caching of conditional requests (if-modified-since), or does not cache them', async () => {
let requestsToOrigin = 0
const body = ''
const code = 304
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.statusCode = code
res.setHeader('cache-control', 'public, max-age=60')
res.end(body)
}).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')
equal(requestsToOrigin, 0)
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
'if-modified-since': new Date().toUTCString()
}
}
// First request should hit the origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
// Second request with different since should hit the origin too
request.headers['if-modified-since'] = new Date(0).toUTCString()
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
equal(res.statusCode, code)
strictEqual(await res.body.text(), body)
}
})
test('stale-while-revalidate returns stale immediately and revalidates in background (RFC 5861)', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
let serverResponse = 'original-response'
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
const responseDate = new Date()
res.setHeader('date', responseDate.toUTCString())
res.setHeader('cache-control', 'max-age=1, stale-while-revalidate=10')
if (req.headers['if-modified-since']) {
revalidationRequests++
// Return updated content on revalidation
serverResponse = 'revalidated-response'
res.end(serverResponse)
} else {
requestsToOrigin++
res.end(serverResponse)
}
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Send initial request to cache the response
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'original-response')
}
// Wait for response to become stale
await sleep(1100)
// Request stale content - should return immediately with stale content
const startTime = Date.now()
{
const res = await client.request(request)
const responseTime = Date.now() - startTime
// Should return stale content immediately (< 50ms)
equal(res.statusCode, 200)
strictEqual(await res.body.text(), 'original-response')
equal(requestsToOrigin, 1) // No additional origin requests yet
// Response should be immediate (RFC 5861 requirement)
if (responseTime > 100) {
fail(`stale-while-revalidate response took ${responseTime}ms, should be < 100ms`)
}
}
// Wait for background revalidation to complete
await sleep(500)
// Verify that revalidation occurred in background
equal(revalidationRequests, 1, 'Background revalidation should have occurred')
})
test('stale-while-revalidate updates cache after background revalidation (receiving new data)', async () => {
let requestsToOrigin = 0
let revalidationRequests = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
const responseDate = new Date()
res.setHeader('date', responseDate.toUTCString())
res.setHeader('cache-control', 'max-age=1, stale-while-revalidate=10')
if (req.headers['if-modified-since']) {
revalidationRequests++
// Return updated content
res.end('updated-response')
} else {
requestsToOrigin++
res.end('original-response')
}
}).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')
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// Initial request
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'original-response')
}
// Wait for staleness
await sleep(1100)
// First stale request - gets stale content immediately
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'original-response')
}
// Wait for the background revalidation to complete and the cache to be
// updated with the revalidated content. Poll until the cache actually
// returns the updated response instead of relying on a fixed sleep, which
// is flaky on slow/loaded CI runners (the revalidation round-trip plus
// cache write can take longer than a hardcoded delay).
{
const deadline = Date.now() + 5000
let body = 'original-response'
while (Date.now() < deadline && body === 'original-response') {
const res = await client.request(request)
body = await res.body.text()
if (body === 'original-response') {
await sleep(50)
}
}
strictEqual(body, 'updated-response')
equal(requestsToOrigin, 1) // Still only one origin request
equal(revalidationRequests >= 1, true, 'Background revalidation should have occurred')
}
})
describe('origins option', () => {
test('caches request when origin matches string in whitelist', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({
origins: [origin]
}))
after(() => client.close())
const request = {
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
// Second request should be served from cache
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
})
test('skips caching when origin does not match string in whitelist', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('not cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: ['http://example.com']
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'not cached')
}
// Second request should also hit origin (not cached)
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'not cached')
}
})
test('caches request when origin matches RegExp in whitelist', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: [/localhost/]
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
// Second request should be served from cache
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
})
test('skips caching when origin does not match RegExp in whitelist', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('not cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: [/example\.com/]
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'not cached')
}
// Second request should also hit origin (not cached)
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'not cached')
}
})
test('caches request when origin matches any entry in mixed array', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: ['http://other.com', /localhost/]
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
// Second request should be served from cache (matches RegExp)
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
})
test('caches all origins when origins option is undefined (default behavior)', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
// Second request should be served from cache
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
})
test('caches nothing when origins is empty array', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('not cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: []
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'not cached')
}
// Second request should also hit origin (not cached)
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'not cached')
}
})
test('throws TypeError when origins is not an array', async () => {
const { throws } = require('node:assert')
throws(
() => interceptors.cache({ origins: 'http://example.com' }),
{
name: 'TypeError',
message: /expected opts\.origins to be an array or undefined/i
}
)
})
test('throws TypeError when origins array contains invalid type', async () => {
const { throws } = require('node:assert')
throws(
() => interceptors.cache({ origins: [123] }),
{
name: 'TypeError',
message: /expected opts\.origins\[0\] to be a string or RegExp/i
}
)
})
test('string matching is case insensitive', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({
origins: [origin.toUpperCase()]
}))
after(() => client.close())
const request = {
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
// Second request should be served from cache (case insensitive match)
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'cached')
}
})
test('different hosts are treated as different origins', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=10')
res.end('not cached')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({
origins: ['example.com']
}))
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}
// First request should hit origin
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'not cached')
}
// Second request should also hit origin (different host = different origin)
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'not cached')
}
})
})
describe('determineDeleteAt', () => {
test('max-age response has deleteAt proportional to freshness lifetime, not 1 year', async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('date', new Date(clock.now).toUTCString())
res.sendDate = false
res.end('short-lived')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/delete-at-maxage' })
strictEqual(await res.body.text(), 'short-lived')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-maxage', headers: {} }))
notEqual(cached, undefined)
// deleteAt should be approximately 2x max-age (staleAt + freshnessLifetime),
// not 1 year out
const maxExpected = clock.now + (60 * 1000 * 3) // generous upper bound
equal(cached.deleteAt < maxExpected, true, `deleteAt (${cached.deleteAt}) should be well under 3x max-age (${maxExpected})`)
equal(cached.deleteAt > cached.staleAt, true, 'deleteAt should be greater than staleAt to allow revalidation')
})
test('sqlite store keeps short-lived entries past Date header precision loss so they can revalidate', { skip: runtimeFeatures.has('sqlite') === false }, async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
const store = new SqliteCacheStore()
let requestsToOrigin = 0
let revalidationHeaders
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
if (requestsToOrigin === 2) {
revalidationHeaders = req.headers
if (req.headers['if-none-match'] !== '"abcd"' || typeof req.headers['if-modified-since'] !== 'string') {
res.statusCode = 412
res.end('expected conditional revalidation')
return
}
res.statusCode = 304
res.end()
return
}
res.setHeader('cache-control', 'max-age=2, must-revalidate')
// Deliberately round the response date down to the previous second.
res.setHeader('date', new Date(0).toUTCString())
res.setHeader('etag', '"abcd"')
res.sendDate = false
res.end('short-lived')
}).listen(0)
after(async () => {
server.close()
store.close()
await client.close()
clock.uninstall()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store, type: 'private' }))
const request = {
origin,
method: 'GET',
path: '/date-header-precision'
}
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'short-lived')
equal(requestsToOrigin, 1)
}
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'short-lived')
equal(requestsToOrigin, 1)
}
clock.tick(3001)
const cached = store.get(makeCacheKey({ ...request, headers: {} }))
notEqual(cached, undefined, 'entry should remain available long enough to be revalidated')
{
const res = await client.request(request)
strictEqual(await res.body.text(), 'short-lived')
equal(requestsToOrigin, 2)
strictEqual(revalidationHeaders['if-none-match'], '"abcd"')
strictEqual(typeof revalidationHeaders['if-modified-since'], 'string')
}
})
test('future Date header does not extend max-age freshness', async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('date', new Date(clock.now + 86400000).toUTCString())
res.sendDate = false
res.end('future-date')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/future-date' })
strictEqual(await res.body.text(), 'future-date')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/future-date', headers: {} }))
notEqual(cached, undefined)
equal(cached.staleAt <= clock.now + 60000, true, `staleAt (${cached.staleAt}) should not be extended by a future Date header`)
equal(cached.staleAt > clock.now, true, 'entry should still be freshly cacheable for max-age')
})
test('future Date header does not extend Expires freshness', async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
const responseDate = clock.now + 86400000
res.setHeader('date', new Date(responseDate).toUTCString())
res.setHeader('expires', new Date(responseDate + 60000).toUTCString())
res.sendDate = false
res.end('future-expires')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/future-expires' })
strictEqual(await res.body.text(), 'future-expires')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/future-expires', headers: {} }))
notEqual(cached, undefined)
equal(cached.staleAt <= clock.now + 60000, true, `staleAt (${cached.staleAt}) should not be extended by a future Date header`)
equal(cached.staleAt > clock.now, true, 'entry should still be freshly cacheable until Expires')
})
test('Age header reduces remaining max-age freshness', async () => {
const clock = FakeTimers.install({ now: 100000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, max-age=60')
res.setHeader('age', '30')
res.setHeader('date', new Date(clock.now).toUTCString())
res.sendDate = false
res.end('aged')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/aged' })
strictEqual(await res.body.text(), 'aged')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/aged', headers: {} }))
notEqual(cached, undefined)
equal(cached.staleAt <= clock.now + 30000, true, `staleAt (${cached.staleAt}) should account for Age`)
equal(cached.staleAt > clock.now, true, 'entry should remain fresh for the unexpired max-age remainder')
})
test('immutable response has deleteAt of ~1 year', async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, immutable')
res.setHeader('date', new Date(clock.now).toUTCString())
res.sendDate = false
res.end('immutable-content')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/delete-at-immutable' })
strictEqual(await res.body.text(), 'immutable-content')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-immutable', headers: {} }))
notEqual(cached, undefined)
const oneYear = 31536000000
// deleteAt should be approximately 1 year out
equal(cached.deleteAt >= clock.now + oneYear - 1000, true, `deleteAt (${cached.deleteAt}) should be ~1 year out`)
// staleAt should also be approximately 1 year out (not ~8.7 hours)
equal(cached.staleAt >= clock.now + oneYear - 1000, true, `staleAt (${cached.staleAt}) should be ~1 year out`)
})
test('stale-while-revalidate extends deleteAt beyond staleAt', async () => {
const clock = FakeTimers.install({ now: 1000, toFake: ['Date'] })
after(() => clock.uninstall())
const store = new MemoryCacheStore()
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
res.setHeader('cache-control', 'public, max-age=60, stale-while-revalidate=300')
res.setHeader('date', new Date(clock.now).toUTCString())
res.sendDate = false
res.end('swr-content')
}).listen(0)
after(async () => {
server.close()
await client.close()
})
await once(server, 'listening')
const origin = `http://localhost:${server.address().port}`
const client = new Client(origin)
.compose(interceptors.cache({ store }))
const res = await client.request({ origin, method: 'GET', path: '/delete-at-swr' })
strictEqual(await res.body.text(), 'swr-content')
const cached = store.get(makeCacheKey({ origin, method: 'GET', path: '/delete-at-swr', headers: {} }))
notEqual(cached, undefined)
// deleteAt should be staleAt + stale-while-revalidate (300s)
const expectedDeleteAt = cached.staleAt + (300 * 1000)
equal(cached.deleteAt, expectedDeleteAt, `deleteAt (${cached.deleteAt}) should be staleAt + 300s (${expectedDeleteAt})`)
})
})
// https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
describe('RFC 9111 §3.5 - Storing Responses to Authenticated Requests', () => {
test('caches response when request has Authorization and response has public directive', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60')
res.end('authenticated')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
})
test('caches response when request has Authorization and response has s-maxage directive', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=60')
res.end('authenticated')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
})
test('caches response when request has Authorization and response has must-revalidate directive', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'must-revalidate, max-age=60')
res.end('authenticated')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
})
test('does not cache response when request has Authorization and qualified no-cache/private names Authorization with OWS', async () => {
for (const cacheControl of [
'public, max-age=60, private=" authorization"',
'public, max-age=60, no-cache="\tauthorization"',
'public, max-age=60, no-cache=authorization\t',
'public, max-age=60, private= "authorization"',
'public, max-age=60, private ="authorization"',
'public, max-age=60, private\t=\t"authorization"',
'public, max-age=60, private="authorization"\t, immutable',
'public, max-age=60, private="authorization" garbage',
'public, max-age=60, private=, private="authorization"',
'public, max-age=60, no-cache= "authorization"',
'public, max-age=60, no-cache ="authorization"',
'public, max-age=60, no-cache \t= \t"authorization"',
'public, max-age=60, no-cache= "\tauthorization\t"\t',
'public, max-age=60, no-cache="authorization" garbage',
'public, max-age=60, no-cache="set-cookie" garbage, no-cache="authorization"',
'public, max-age=60, no-cache=, no-cache="authorization"'
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', cacheControl)
res.end(`authenticated ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated 1')
}
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'authenticated 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('does not cache response when request has Authorization and only invalid permissive directives', async () => {
for (const cacheControl of [
'public =, max-age=60',
'public= , max-age=60',
'must-revalidate =, max-age=60',
'public\u00a0, max-age=60',
'must-revalidate\u00a0, max-age=60',
'public=bad, public, max-age=60',
'must-revalidate=bad, must-revalidate, max-age=60',
'extension="x, public, s-maxage=60, y", max-age=60',
'extension="x, public, s-maxage=60, max-age=60',
['extension="x', 'public, s-maxage=60, max-age=60'],
'public, max-age=60, extension="x, private',
's-maxage =60',
's-maxage= 60',
's-maxage=60x',
's-maxage=bad, s-maxage=60',
's-maxage=60, s-maxage=bad',
's-maxage =60, s-maxage=60',
'no-store=false, public, max-age=60',
'public, max-age=60, max-age=0',
's-maxage=60, s-maxage=0',
'public, max-age=60, private\u00a0',
'public, max-age=60, priv\u00a0ate'
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', cacheControl)
res.end(`authenticated ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1, cacheControl)
strictEqual(await res.body.text(), 'authenticated 1')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2, cacheControl)
strictEqual(await res.body.text(), 'authenticated 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('does not cache Authorization response with malformed s-maxage in shared cache', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
res.setHeader('cache-control', 's-maxage=60x')
if (req.headers.authorization === 'Bearer tenant-a-token') {
res.end('SECRET_FOR_AUTHENTICATED_REQUEST')
return
}
res.end('PUBLIC_RESPONSE')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache({ type: 'shared' }))
after(() => client.close())
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer tenant-a-token'
}
})
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'SECRET_FOR_AUTHENTICATED_REQUEST')
}
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'PUBLIC_RESPONSE')
}
})
test('does not cache shared response with empty qualified private directive', async () => {
for (const cacheControl of [
'public, max-age=60, private=""',
'public, max-age=60, private=","',
'public, max-age=60, private=" "'
]) {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
requestsToOrigin++
const who = req.headers.authorization || '(no-auth)'
res.setHeader('cache-control', cacheControl)
res.setHeader('set-cookie', `session=secret-for-${who}`)
res.end(`authenticated ${who} ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
})
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated Bearer token123 1')
}
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'authenticated (no-auth) 2')
}
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
}
})
test('does not crash on mixed unqualified and qualified private directives', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'public, max-age=60, private, private="hdr"')
res.end(`response ${requestsToOrigin}`)
}).listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
try {
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'response 1')
} finally {
await client.close()
await new Promise(resolve => server.close(resolve))
}
})
test('does not cache response when request has Authorization and response only has max-age', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=60')
res.end('authenticated')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'authenticated')
}
})
test('does not cache response when request has empty Authorization and response only has max-age', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.setHeader('cache-control', 'max-age=60')
res.end(`authenticated ${requestsToOrigin}`)
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: ''
}
})
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated 1')
}
{
const res = await client.request({
origin: 'localhost',
method: 'GET',
path: '/'
})
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'authenticated 2')
}
})
test('does not cache response when request has Authorization and no cache directives', async () => {
let requestsToOrigin = 0
const server = createServer({ joinDuplicateHeaders: true }, (_, res) => {
requestsToOrigin++
res.end('authenticated')
}).listen(0)
after(() => server.close())
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`)
.compose(interceptors.cache())
after(() => client.close())
const request = {
origin: 'localhost',
method: 'GET',
path: '/',
headers: {
authorization: 'Bearer token123'
}
}
{
const res = await client.request(request)
equal(requestsToOrigin, 1)
strictEqual(await res.body.text(), 'authenticated')
}
{
const res = await client.request(request)
equal(requestsToOrigin, 2)
strictEqual(await res.body.text(), 'authenticated')
}
})
})
})