Skip to content

Commit

Permalink
feat: http caching
Browse files Browse the repository at this point in the history
Implements bare-bones http caching as per rfc9111

Closes #3231
Closes #2760
Closes #2256
Closes #1146

Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>

Co-authored-by: Carlos Fuentes <me@metcoder.dev>
Co-authored-by: Robert Nagy <ronagy@icloud.com>
  • Loading branch information
3 people committed Sep 11, 2024
1 parent b66fb4b commit 9d1cb32
Show file tree
Hide file tree
Showing 9 changed files with 874 additions and 1 deletion.
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ module.exports.RedirectHandler = RedirectHandler
module.exports.interceptors = {
redirect: require('./lib/interceptor/redirect'),
retry: require('./lib/interceptor/retry'),
dump: require('./lib/interceptor/dump')
dump: require('./lib/interceptor/dump'),
cache: require('./lib/interceptor/cache')
}

module.exports.cacheStores = {
LruCacheStore: require('./lib/cache/lru-cache-store')
}

module.exports.buildConnector = buildConnector
Expand Down
84 changes: 84 additions & 0 deletions lib/cache/lru-cache-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use strict'

const { canServeStale } = require('../util/cache.js')

/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @implements {CacheStore}
*/
class LruCacheStore {
/**
* @type {Map<string, import('../../types/cache-interceptor.d.ts').default.CacheStoreValue[]>}
*/
#data = new Map()

/**
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @returns {Promise<import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined>}
*/
get (req) {
const key = this.#makeKey(req)

const values = this.#data.get(key)
if (!values) {
return
}

let needsFlattening = false
const now = Date.now()
let value
for (let i = 0; i < values.length; i++) {
const current = values[i]
if (now >= current.staleAt && !canServeStale(current)) {
delete values[i]
needsFlattening = true
continue
}

let matches = true
for (const key in current.vary) {
if (current.vary[key] !== req.headers[key]) {
matches = false
break
}
}

if (matches) {
value = current
break
}
}

if (needsFlattening) {
this.#data.set(key, values.filter(() => true))
}

return value
}

/**
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value
*/
put (req, value) {
const key = this.#makeKey(req)

let arr = this.#data.get(key)
if (!arr) {
arr = []
this.#data.set(key, arr)
}
arr.push(value)
}

/**
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @returns {string}
*/
#makeKey (req) {
// https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
return `${req.origin}:${req.path}:${req.method}`
}
}

module.exports = LruCacheStore
218 changes: 218 additions & 0 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
'use strict'

const util = require('../core/util.js')
const DecoratorHandler = require('../handler/decorator-handler')
const {
parseCacheControlHeader,
cacheDirectivesAllowCaching,
parseVaryHeader
} = require('../util/cache.js')

/**
* This is the handler responsible for writing a request's response to a cache
* store.
*
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches
*/
class CacheHandler extends DecoratorHandler {
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheOptions}
*/
#opts = null
/**
* @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
*/
#req = null
/**
* @type {import('../../types/dispatcher.d.ts').default.DispatchHandlers}
*/
#handler = null
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined}
*/
#value = null

/**
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} opts
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
*/
constructor (opts, req, handler) {
super(handler)

this.#opts = opts
this.#req = req
this.#handler = handler
}

onHeaders (
statusCode,
rawHeaders,
resume,
statusMessage,
headers = util.parseHeaders(rawHeaders)
) {
const cacheControlHeader = headers['cache-control']
const contentLengthHeader = headers['content-length']
// TODO read cache control directives to see if we can cache requests with
// authorization headers
// https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-in-caches
if (!cacheControlHeader || !contentLengthHeader || headers['authorization']) {
return this.#handler.onHeaders(
statusCode,
rawHeaders,
resume,
statusMessage,
headers
)
}

const contentLength = Number(contentLengthHeader)
const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)
const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity

if (
!isNaN(contentLength) &&
maxEntrySize > contentLength &&
cacheDirectivesAllowCaching(cacheControlDirectives, headers.vary) &&
[200, 307].includes(statusCode)
) {
const varyDirectives = headers.vary
? parseVaryHeader(headers.vary)
: undefined

const ttl = determineTtl(headers, cacheControlDirectives) * 1000
if (ttl > 0) {
const strippedHeaders = stripNecessaryHeaders(rawHeaders, headers)

const now = Date.now()
this.#value = {
complete: false,
data: {
statusCode,
statusMessage,
rawHeaders: strippedHeaders,
rawTrailers: null,
body: []
},
cachingDirectives: cacheControlDirectives,
vary: varyDirectives,
size: (rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) +
(statusMessage?.length ?? 0) +
64,
cachedAt: now,
staleAt: now + ttl,
deleteAt: 0 // TODO
}
}
}

return this.#handler.onHeaders(
statusCode,
rawHeaders,
resume,
statusMessage,
headers
)
}

onData (chunk) {
if (this.#value) {
this.#value.size += chunk.bodyLength

const maxEntrySize = this.#opts.store.maxEntrySize ?? Infinity
if (this.#value.size > maxEntrySize) {
this.#value = null
} else {
this.#value.data.body.push(chunk)
}
}

return this.#handler.onData(chunk)
}

onComplete (rawTrailers) {
if (this.#value) {
this.#value.complete = true
this.#value.data.rawTrailers = rawTrailers
this.#value.size += rawTrailers?.reduce((xs, x) => xs + x.length, 0) ?? 0

this.#opts.store.put(this.#req, this.#value).catch(err => {
throw err
})
}

return this.#handler.onComplete(rawTrailers)
}

onError (err) {
this.#value = undefined
this.#handler.onError(err)
}
}

/**
* @param {Record<string, string>} headers
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
* @returns ttl for an object in seconds, 0 if it shouldn't be cached
*/
function determineTtl (headers, cacheControlDirectives) {
// Prioritize s-maxage since we're a shared cache
// s-maxage > max-age > Expire
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3
const sMaxAge = cacheControlDirectives['s-maxage']
if (sMaxAge) {
return sMaxAge
}

if (cacheControlDirectives.immutable) {
// https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2
return 31536000
}

const maxAge = cacheControlDirectives['max-age']
if (maxAge) {
return maxAge
}

if (headers.expire) {
// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
return (new Date() - new Date(headers.expire)) / 1000
}

return 0
}

const HEADERS_TO_REMOVE = [
'connection'
]

/**
* Strips headers required to be removed in cached responses
* @param {Buffer[]} rawHeaders
* @param {string[]} parsedHeaders
* @returns {Buffer[]}
*/
function stripNecessaryHeaders (rawHeaders, parsedHeaders) {
let strippedRawHeaders
for (let i = 0; i < parsedHeaders.length; i++) {
const header = parsedHeaders[i]
const kvDelimiterIndex = header.indexOf(':')
const headerName = header.substring(0, kvDelimiterIndex)

if (headerName in HEADERS_TO_REMOVE) {
if (!strippedRawHeaders) {
strippedRawHeaders = rawHeaders.slice(0, n - 1)

Check failure on line 205 in lib/handler/cache-handler.js

View workflow job for this annotation

GitHub Actions / Lint

'n' is not defined
}
} else if (strippedRawHeaders) {
strippedRawHeaders.push(rawHeaders[n])

Check failure on line 208 in lib/handler/cache-handler.js

View workflow job for this annotation

GitHub Actions / Lint

'n' is not defined
}
}
strippedRawHeaders ??= rawHeaders

return strippedRawHeaders
? strippedRawHeaders.filter(() => true)
: rawHeaders
}

module.exports = CacheHandler
Loading

0 comments on commit 9d1cb32

Please sign in to comment.