Skip to content

Commit

Permalink
feat: support request cache control directives
Browse files Browse the repository at this point in the history
Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
  • Loading branch information
flakey5 committed Oct 1, 2024
1 parent 7ea49d3 commit 3ebfdd8
Showing 1 changed file with 96 additions and 5 deletions.
101 changes: 96 additions & 5 deletions lib/interceptor/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,52 @@
const CacheHandler = require('../handler/cache-handler')
const MemoryCacheStore = require('../cache/memory-cache-store')
const CacheRevalidationHandler = require('../handler/cache-revalidation-handler')
const { UNSAFE_METHODS } = require('../util/cache.js')
const { UNSAFE_METHODS, parseCacheControlHeader } = require('../util/cache.js')

const AGE_HEADER = Buffer.from('age')

/**
* @param {number} now
* @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value
* @param {number} age
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
*/
function needsRevalidation (now, value, age, cacheControlDirectives) {
if (now > value.staleAt) {
// Response is stale
if (cacheControlDirectives?.['max-stale']) {
// Check if the request doesn't mind a stale response
// https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale
const gracePeriod = value.staleAt + (cacheControlDirectives['max-stale'] * 1000)

return now > gracePeriod
}

return true
}

if (cacheControlDirectives?.['no-cache']) {
// Always revalidate request with the no-cache parameter
return true
}

if (
cacheControlDirectives &&
cacheControlDirectives['max-age'] !== undefined &&
cacheControlDirectives['max-stale'] !== undefined
) {
return true
}

if (cacheControlDirectives?.['min-fresh']) {
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
const gracePeriod = age + (cacheControlDirectives['min-fresh'] * 1000)
return (now - value.staleAt) > gracePeriod
}

return false
}

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions | undefined} globalOpts
* @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor}
Expand Down Expand Up @@ -47,19 +89,63 @@ module.exports = globalOpts => {

return dispatch => {
return (opts, handler) => {
if (!opts.origin || !methods.includes(opts.method)) {
const requestCacheControl = opts.headers?.['cache-control']
? parseCacheControlHeader(opts.headers['cache-control'])
: undefined

if (
!opts.origin ||
!methods.includes(opts.method) ||
requestCacheControl?.['no-store']
) {
// Not a method we want to cache or we don't have the origin, skip
return dispatch(opts, handler)
}

const stream = globalOpts.store.createReadStream(opts)
if (!stream) {
// Request isn't cached
if (requestCacheControl?.['only-if-cached']) {
const ac = new AbortController()
const signal = ac.signal

// We only want cached responses
// https://www.rfc-editor.org/rfc/rfc9111.html#name-only-if-cached
try {
if (typeof handler.onConnect === 'function') {
handler.onConnect(ac.abort)
signal.throwIfAborted()
}

if (typeof handler.onHeaders === 'function') {
handler.onHeaders(504, [], () => {}, 'Gateway Timeout')
signal.throwIfAborted()
}

if (typeof handler.onComplete === 'function') {
handler.onComplete([])
}
} catch (err) {
if (typeof handler.onError === 'function') {
handler.onError(err)
}
}
}

// Dispatch it and add it to the cache
return dispatch(opts, new CacheHandler(globalOpts, opts, handler))
}

const { value } = stream

const age = Math.round((Date.now() - value.cachedAt) / 1000)
if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) {
// Response is considered expired for this specific request
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
// TODO we could also pass this to the cache handler to re-cache this if we want
return dispatch(opts, handler)
}

// Dump body on error
if (typeof opts.body === 'object' && opts.body.constructor.name === 'Readable') {
opts.body?.on('error', () => {}).resume()
Expand Down Expand Up @@ -112,10 +198,15 @@ module.exports = globalOpts => {
}
}

// Check if the response is stale
// Check if the response needs revalidation
// Reasons for this,
// 1) the response is stale
// 2) the request gives the no-cache directive
// 3)
const now = Date.now()
if (now >= value.staleAt) {
if (now >= value.deleteAt) {

if (needsRevalidation(now, value, age, requestCacheControl)) {
if (now > value.deleteAt) {
// Safety check in case the store gave us a response that should've been
// deleted already
dispatch(opts, new CacheHandler(globalOpts, opts, handler))
Expand Down

0 comments on commit 3ebfdd8

Please sign in to comment.