Skip to content

Commit

Permalink
feat: cache etag support (#3758)
Browse files Browse the repository at this point in the history
* feat: cache etag support

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

* Apply suggestions from code review

Co-authored-by: Carlos Fuentes <me@metcoder.dev>

* code review

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

* fixup

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

* fixup

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

---------

Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com>
Co-authored-by: Carlos Fuentes <me@metcoder.dev>
  • Loading branch information
flakey5 and metcoder95 authored Nov 18, 2024
1 parent 24df4a5 commit a73fd09
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 9 deletions.
6 changes: 3 additions & 3 deletions docs/docs/api/CacheStore.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,19 @@ If the request isn't cached, `undefined` is returned.

Response properties:

* **response** `CachedResponse` - The cached response data.
* **response** `CacheValue` - The cached response data.
* **body** `Readable | undefined` - The response's body.

### Function: `createWriteStream`

Parameters:

* **req** `Dispatcher.RequestOptions` - Incoming request
* **value** `CachedResponse` - Response to store
* **value** `CacheValue` - Response to store

Returns: `Writable | undefined` - If the store is full, return `undefined`. Otherwise, return a writable so that the cache interceptor can stream the body and trailers to the store.

## `CachedResponse`
## `CacheValue`

This is an interface containing the majority of a response's data (minus the body).

Expand Down
1 change: 1 addition & 0 deletions lib/cache/memory-cache-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class MemoryCacheStore {
statusCode: entry.statusCode,
rawHeaders: entry.rawHeaders,
body: entry.body,
etag: entry.etag,
cachedAt: entry.cachedAt,
staleAt: entry.staleAt,
deleteAt: entry.deleteAt
Expand Down
16 changes: 13 additions & 3 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const util = require('../core/util')
const DecoratorHandler = require('../handler/decorator-handler')
const {
parseCacheControlHeader,
parseVaryHeader
parseVaryHeader,
isEtagUsable
} = require('../util/cache')
const { nowAbsolute } = require('../util/timers.js')

Expand Down Expand Up @@ -136,15 +137,24 @@ class CacheHandler extends DecoratorHandler {
cacheControlDirectives
)

this.#writeStream = this.#store.createWriteStream(this.#cacheKey, {
/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
*/
const value = {
statusCode,
statusMessage,
rawHeaders: strippedHeaders,
vary: varyDirectives,
cachedAt: now,
staleAt,
deleteAt
})
}

if (typeof headers.etag === 'string' && isEtagUsable(headers.etag)) {
value.etag = headers.etag
}

this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)

if (this.#writeStream) {
const handler = this
Expand Down
3 changes: 2 additions & 1 deletion lib/interceptor/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ module.exports = (opts = {}) => {
...opts,
headers: {
...opts.headers,
'if-modified-since': new Date(result.cachedAt).toUTCString()
'if-modified-since': new Date(result.cachedAt).toUTCString(),
etag: result.etag
}
},
new CacheRevalidationHandler(
Expand Down
35 changes: 35 additions & 0 deletions lib/util/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,40 @@ function parseVaryHeader (varyHeader, headers) {
return output
}

/**
* Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
* however, including them in cached resposnes serves little to no purpose.
*
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
*
* @param {string} etag
* @returns {boolean}
*/
function isEtagUsable (etag) {
if (etag.length <= 2) {
// Shortest an etag can be is two chars (just ""). This is where we deviate
// from the spec requiring a min of 3 chars however
return false
}

if (etag[0] === '"' && etag[etag.length - 1] === '"') {
// ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the
// spec. Some servers will accept these while others don't.
// ETag: "asd123"
return !(etag[1] === '"' || etag.startsWith('"W/'))
}

if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') {
// ETag: W/"", also where we deviate from the spec & require a min of 3
// chars
// ETag: for W/"", W/"asd123"
return etag.length !== 4
}

// Anything else
return false
}

/**
* @param {unknown} store
* @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
Expand Down Expand Up @@ -244,6 +278,7 @@ module.exports = {
makeCacheKey,
parseCacheControlHeader,
parseVaryHeader,
isEtagUsable,
assertCacheMethods,
assertCacheStore
}
4 changes: 4 additions & 0 deletions test/cache-interceptor/cache-stores.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function cacheStoreTests (CacheStore) {

deepStrictEqual(await readResponse(readResult), {
...requestValue,
etag: undefined,
body: requestBody
})

Expand Down Expand Up @@ -94,6 +95,7 @@ function cacheStoreTests (CacheStore) {
notEqual(readResult, undefined)
deepStrictEqual(await readResponse(readResult), {
...anotherValue,
etag: undefined,
body: anotherBody
})
})
Expand Down Expand Up @@ -127,6 +129,7 @@ function cacheStoreTests (CacheStore) {
const readResult = store.get(request)
deepStrictEqual(await readResponse(readResult), {
...requestValue,
etag: undefined,
body: requestBody
})
})
Expand Down Expand Up @@ -198,6 +201,7 @@ function cacheStoreTests (CacheStore) {
const { vary, ...responseValue } = requestValue
deepStrictEqual(await readResponse(readStream), {
...responseValue,
etag: undefined,
body: requestBody
})

Expand Down
29 changes: 27 additions & 2 deletions test/cache-interceptor/utils.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use strict'

const { describe, test } = require('node:test')
const { deepStrictEqual } = require('node:assert')
const { parseCacheControlHeader, parseVaryHeader } = require('../../lib/util/cache')
const { deepStrictEqual, equal } = require('node:assert')
const { parseCacheControlHeader, parseVaryHeader, isEtagUsable } = require('../../lib/util/cache')

describe('parseCacheControlHeader', () => {
test('all directives are parsed properly when in their correct format', () => {
Expand Down Expand Up @@ -215,3 +215,28 @@ describe('parseVaryHeader', () => {
})
})
})

describe('isEtagUsable', () => {
const valuesToTest = {
// Invalid etags
'': false,
asd: false,
'"W/"asd""': false,
'""asd""': false,

// Valid etags
'"asd"': true,
'W/"ads"': true,

// Spec deviations
'""': false,
'W/""': false
}

for (const key in valuesToTest) {
const expectedValue = valuesToTest[key]
test(`\`${key}\` = ${expectedValue}`, () => {
equal(isEtagUsable(key), expectedValue)
})
}
})
68 changes: 68 additions & 0 deletions test/interceptors/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,74 @@ describe('Cache Interceptor', () => {
strictEqual(await response.body.text(), 'asd123')
})

test('revalidates request w/ etag when provided', async (t) => {
let requestsToOrigin = 0

const clock = FakeTimers.install({
shouldClearNativeTimers: true
})
tick(0)

const server = createServer((req, res) => {
res.setHeader('cache-control', 'public, s-maxage=1, stale-while-revalidate=10')
requestsToOrigin++

if (requestsToOrigin > 1) {
equal(req.headers['etag'], '"asd123"')

if (requestsToOrigin === 3) {
res.end('asd123')
} else {
res.statusCode = 304
res.end()
}
} else {
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()
clock.uninstall()
})

await once(server, 'listening')

strictEqual(requestsToOrigin, 0)

const request = {
origin: 'localhost',
method: 'GET',
path: '/'
}

// Send initial request. This should reach the origin
let response = await client.request(request)
strictEqual(requestsToOrigin, 1)
strictEqual(await response.body.text(), 'asd')

clock.tick(1500)
tick(1500)

// Now we send two more requests. Both of these should reach the origin,
// but now with a conditional header asking if the resource has been
// updated. These need to be ran after the response is stale.
// No update for the second request
response = await client.request(request)
strictEqual(requestsToOrigin, 2)
strictEqual(await response.body.text(), 'asd')

// This should be updated, even though the value isn't expired.
response = await client.request(request)
strictEqual(requestsToOrigin, 3)
strictEqual(await response.body.text(), 'asd123')
})

test('respects cache store\'s isFull property', async () => {
const server = createServer((_, res) => {
res.end('asd')
Expand Down
1 change: 1 addition & 0 deletions types/cache-interceptor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ declare namespace CacheHandler {
statusMessage: string
rawHeaders: Buffer[]
vary?: Record<string, string | string[]>
etag?: string
cachedAt: number
staleAt: number
deleteAt: number
Expand Down

0 comments on commit a73fd09

Please sign in to comment.