Skip to content

Commit a73fd09

Browse files
flakey5metcoder95
andauthored
feat: cache etag support (#3758)
* feat: cache etag support Signed-off-by: flakey5 <[email protected]> * Apply suggestions from code review Co-authored-by: Carlos Fuentes <[email protected]> * code review Signed-off-by: flakey5 <[email protected]> * fixup Signed-off-by: flakey5 <[email protected]> * fixup Signed-off-by: flakey5 <[email protected]> --------- Signed-off-by: flakey5 <[email protected]> Co-authored-by: Carlos Fuentes <[email protected]>
1 parent 24df4a5 commit a73fd09

File tree

9 files changed

+154
-9
lines changed

9 files changed

+154
-9
lines changed

docs/docs/api/CacheStore.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,19 @@ If the request isn't cached, `undefined` is returned.
3636

3737
Response properties:
3838

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

4242
### Function: `createWriteStream`
4343

4444
Parameters:
4545

4646
* **req** `Dispatcher.RequestOptions` - Incoming request
47-
* **value** `CachedResponse` - Response to store
47+
* **value** `CacheValue` - Response to store
4848

4949
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.
5050

51-
## `CachedResponse`
51+
## `CacheValue`
5252

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

lib/cache/memory-cache-store.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class MemoryCacheStore {
9191
statusCode: entry.statusCode,
9292
rawHeaders: entry.rawHeaders,
9393
body: entry.body,
94+
etag: entry.etag,
9495
cachedAt: entry.cachedAt,
9596
staleAt: entry.staleAt,
9697
deleteAt: entry.deleteAt

lib/handler/cache-handler.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const util = require('../core/util')
44
const DecoratorHandler = require('../handler/decorator-handler')
55
const {
66
parseCacheControlHeader,
7-
parseVaryHeader
7+
parseVaryHeader,
8+
isEtagUsable
89
} = require('../util/cache')
910
const { nowAbsolute } = require('../util/timers.js')
1011

@@ -136,15 +137,24 @@ class CacheHandler extends DecoratorHandler {
136137
cacheControlDirectives
137138
)
138139

139-
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, {
140+
/**
141+
* @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
142+
*/
143+
const value = {
140144
statusCode,
141145
statusMessage,
142146
rawHeaders: strippedHeaders,
143147
vary: varyDirectives,
144148
cachedAt: now,
145149
staleAt,
146150
deleteAt
147-
})
151+
}
152+
153+
if (typeof headers.etag === 'string' && isEtagUsable(headers.etag)) {
154+
value.etag = headers.etag
155+
}
156+
157+
this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
148158

149159
if (this.#writeStream) {
150160
const handler = this

lib/interceptor/cache.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ module.exports = (opts = {}) => {
152152
...opts,
153153
headers: {
154154
...opts.headers,
155-
'if-modified-since': new Date(result.cachedAt).toUTCString()
155+
'if-modified-since': new Date(result.cachedAt).toUTCString(),
156+
etag: result.etag
156157
}
157158
},
158159
new CacheRevalidationHandler(

lib/util/cache.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,40 @@ function parseVaryHeader (varyHeader, headers) {
201201
return output
202202
}
203203

204+
/**
205+
* Note: this deviates from the spec a little. Empty etags ("", W/"") are valid,
206+
* however, including them in cached resposnes serves little to no purpose.
207+
*
208+
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
209+
*
210+
* @param {string} etag
211+
* @returns {boolean}
212+
*/
213+
function isEtagUsable (etag) {
214+
if (etag.length <= 2) {
215+
// Shortest an etag can be is two chars (just ""). This is where we deviate
216+
// from the spec requiring a min of 3 chars however
217+
return false
218+
}
219+
220+
if (etag[0] === '"' && etag[etag.length - 1] === '"') {
221+
// ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the
222+
// spec. Some servers will accept these while others don't.
223+
// ETag: "asd123"
224+
return !(etag[1] === '"' || etag.startsWith('"W/'))
225+
}
226+
227+
if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') {
228+
// ETag: W/"", also where we deviate from the spec & require a min of 3
229+
// chars
230+
// ETag: for W/"", W/"asd123"
231+
return etag.length !== 4
232+
}
233+
234+
// Anything else
235+
return false
236+
}
237+
204238
/**
205239
* @param {unknown} store
206240
* @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore}
@@ -244,6 +278,7 @@ module.exports = {
244278
makeCacheKey,
245279
parseCacheControlHeader,
246280
parseVaryHeader,
281+
isEtagUsable,
247282
assertCacheMethods,
248283
assertCacheStore
249284
}

test/cache-interceptor/cache-stores.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function cacheStoreTests (CacheStore) {
5858

5959
deepStrictEqual(await readResponse(readResult), {
6060
...requestValue,
61+
etag: undefined,
6162
body: requestBody
6263
})
6364

@@ -94,6 +95,7 @@ function cacheStoreTests (CacheStore) {
9495
notEqual(readResult, undefined)
9596
deepStrictEqual(await readResponse(readResult), {
9697
...anotherValue,
98+
etag: undefined,
9799
body: anotherBody
98100
})
99101
})
@@ -127,6 +129,7 @@ function cacheStoreTests (CacheStore) {
127129
const readResult = store.get(request)
128130
deepStrictEqual(await readResponse(readResult), {
129131
...requestValue,
132+
etag: undefined,
130133
body: requestBody
131134
})
132135
})
@@ -198,6 +201,7 @@ function cacheStoreTests (CacheStore) {
198201
const { vary, ...responseValue } = requestValue
199202
deepStrictEqual(await readResponse(readStream), {
200203
...responseValue,
204+
etag: undefined,
201205
body: requestBody
202206
})
203207

test/cache-interceptor/utils.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use strict'
22

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

77
describe('parseCacheControlHeader', () => {
88
test('all directives are parsed properly when in their correct format', () => {
@@ -215,3 +215,28 @@ describe('parseVaryHeader', () => {
215215
})
216216
})
217217
})
218+
219+
describe('isEtagUsable', () => {
220+
const valuesToTest = {
221+
// Invalid etags
222+
'': false,
223+
asd: false,
224+
'"W/"asd""': false,
225+
'""asd""': false,
226+
227+
// Valid etags
228+
'"asd"': true,
229+
'W/"ads"': true,
230+
231+
// Spec deviations
232+
'""': false,
233+
'W/""': false
234+
}
235+
236+
for (const key in valuesToTest) {
237+
const expectedValue = valuesToTest[key]
238+
test(`\`${key}\` = ${expectedValue}`, () => {
239+
equal(isEtagUsable(key), expectedValue)
240+
})
241+
}
242+
})

test/interceptors/cache.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,74 @@ describe('Cache Interceptor', () => {
223223
strictEqual(await response.body.text(), 'asd123')
224224
})
225225

226+
test('revalidates request w/ etag when provided', async (t) => {
227+
let requestsToOrigin = 0
228+
229+
const clock = FakeTimers.install({
230+
shouldClearNativeTimers: true
231+
})
232+
tick(0)
233+
234+
const server = createServer((req, res) => {
235+
res.setHeader('cache-control', 'public, s-maxage=1, stale-while-revalidate=10')
236+
requestsToOrigin++
237+
238+
if (requestsToOrigin > 1) {
239+
equal(req.headers['etag'], '"asd123"')
240+
241+
if (requestsToOrigin === 3) {
242+
res.end('asd123')
243+
} else {
244+
res.statusCode = 304
245+
res.end()
246+
}
247+
} else {
248+
res.setHeader('etag', '"asd123"')
249+
res.end('asd')
250+
}
251+
}).listen(0)
252+
253+
const client = new Client(`http://localhost:${server.address().port}`)
254+
.compose(interceptors.cache())
255+
256+
after(async () => {
257+
server.close()
258+
await client.close()
259+
clock.uninstall()
260+
})
261+
262+
await once(server, 'listening')
263+
264+
strictEqual(requestsToOrigin, 0)
265+
266+
const request = {
267+
origin: 'localhost',
268+
method: 'GET',
269+
path: '/'
270+
}
271+
272+
// Send initial request. This should reach the origin
273+
let response = await client.request(request)
274+
strictEqual(requestsToOrigin, 1)
275+
strictEqual(await response.body.text(), 'asd')
276+
277+
clock.tick(1500)
278+
tick(1500)
279+
280+
// Now we send two more requests. Both of these should reach the origin,
281+
// but now with a conditional header asking if the resource has been
282+
// updated. These need to be ran after the response is stale.
283+
// No update for the second request
284+
response = await client.request(request)
285+
strictEqual(requestsToOrigin, 2)
286+
strictEqual(await response.body.text(), 'asd')
287+
288+
// This should be updated, even though the value isn't expired.
289+
response = await client.request(request)
290+
strictEqual(requestsToOrigin, 3)
291+
strictEqual(await response.body.text(), 'asd123')
292+
})
293+
226294
test('respects cache store\'s isFull property', async () => {
227295
const server = createServer((_, res) => {
228296
res.end('asd')

types/cache-interceptor.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ declare namespace CacheHandler {
3030
statusMessage: string
3131
rawHeaders: Buffer[]
3232
vary?: Record<string, string | string[]>
33+
etag?: string
3334
cachedAt: number
3435
staleAt: number
3536
deleteAt: number

0 commit comments

Comments
 (0)