Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cache etag support #3758

Merged
merged 5 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
flakey5 marked this conversation as resolved.
Show resolved Hide resolved
}

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] === '"') {
flakey5 marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading