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 nodejs#3231
Closes nodejs#2760
Closes nodejs#2256
Closes nodejs#1146

Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
  • Loading branch information
flakey5 committed Sep 7, 2024
1 parent 3544311 commit 4bf7c84
Show file tree
Hide file tree
Showing 8 changed files with 622 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
80 changes: 80 additions & 0 deletions lib/cache/lru-cache-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use strict'

/**
* @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 undefined
}

let needsFlattening = false
const now = Date.now()
let value
for (let i = 0; i < values.length; i++) {
const current = values[i]
if (now >= value.expiresAt) {
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)

if (!this.#data.has(key)) {
this.#data.set(key, [])
}

this.#data.get(key).push(value)
}

/**
* @param {import('../../types/dispatcher.d.ts').default.RequestOptions} req
* @returns {string}
*/
#makeKey (req) {
return `${req.origin}:${req.path}:${req.method}`
}
}

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

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

class CacheHandler extends DecoratorHandler {
#globalOpts
#req
#handler
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue | undefined}
*/
#value = undefined

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

this.#globalOpts = globalOpts
this.#req = req
this.#handler = handler
}

onHeaders (
statusCode,
rawHeaders,
resume,
statusMessage,
headers = util.parseHeaders(rawHeaders)
) {
const cacheControlHeader = headers['cache-control']

// Cache control header is missing or the status code isn't what we want,
// let's not try to cache this response
if (!cacheControlHeader || !(statusCode in this.#globalOpts.statusCodes)) {
return this.#handler.onHeaders(
statusCode,
rawHeaders,
resume,
statusMessage,
headers
)
}

const cacheControlDirectives = parseCacheControlHeader(cacheControlHeader)

const contentLength = headers['content-length']
? Number(headers['content-length'])
: Infinity

const maxEntrySize = this.#globalOpts.store.maxEntrySize ?? Infinity

if (
maxEntrySize > contentLength &&
shouldRequestBeCached(cacheControlDirectives, headers.vary)
) {
const varyDirectives = headers.vary
? parseVaryHeader(headers.vary)
: undefined

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

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

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

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

const maxEntrySize = this.#globalOpts.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.#globalOpts.store.put(this.#req, this.#value).catch(err => {
throw err
})
}

return this.#handler.onComplete(rawTrailers)
}
}

/**
* @param {Record<string, string>} headers
* @param {import('../util/cache.js').CacheControlDirectives} cacheControlDirectives
* @returns ttl for an object, 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) {
// Lazy deep clone
strippedRawHeaders = structuredClone(rawHeaders)
}

delete strippedRawHeaders[i]
}
}

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

module.exports = CacheHandler
Loading

0 comments on commit 4bf7c84

Please sign in to comment.