diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index fe56baa82c9..5ad6e5f1565 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -106,6 +106,24 @@ function withinStaleWhileRevalidateWindow (result) { return now <= staleWhileRevalidateExpiry } +/** + * Returns the if-modified-since value for revalidating a stored response. + * https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.3 + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @returns {string} + */ +function getIfModifiedSinceValue (result) { + if (typeof result.headers['last-modified'] === 'string' && result.headers['last-modified'] !== '') { + return result.headers['last-modified'] + } + + if (typeof result.headers.date === 'string' && result.headers.date !== '') { + return result.headers.date + } + + return new Date(result.cachedAt).toUTCString() +} + /** * @param {DispatchFn} dispatch * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts @@ -307,7 +325,7 @@ function handleResult ( queueMicrotask(() => { const headers = { ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() + 'if-modified-since': getIfModifiedSinceValue(result) } if (result.etag) { @@ -351,7 +369,7 @@ function handleResult ( const headers = { ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() + 'if-modified-since': getIfModifiedSinceValue(result) } if (result.etag) { diff --git a/test/interceptors/cache-revalidate-stale.js b/test/interceptors/cache-revalidate-stale.js index 5abc58898a6..17103357014 100644 --- a/test/interceptors/cache-revalidate-stale.js +++ b/test/interceptors/cache-revalidate-stale.js @@ -157,3 +157,173 @@ describe('revalidates the request, handles 304s during stale-while-revalidate', test('using if-none-match', async () => await revalidateTest(true)) test('using if-modified-since', async () => await revalidateTest(false)) }) + +describe('if-modified-since value on revalidation requests (RFC 9110 ยง13.1.3)', () => { + const LAST_MODIFIED = 'Sat, 09 Oct 2010 14:28:02 GMT' + + test('sends the stored last-modified value verbatim', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', new Date(clock.now).toUTCString()) + res.setHeader('Cache-Control', 'public, max-age=1') + res.setHeader('Last-Modified', LAST_MODIFIED) + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // let the response go stale, forcing a revalidation request + clock.tick(1500) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], LAST_MODIFIED) + }) + + test('sends the stored last-modified value verbatim during stale-while-revalidate', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', new Date(clock.now).toUTCString()) + res.setHeader('Cache-Control', 'public, max-age=1, stale-while-revalidate=3600') + res.setHeader('Last-Modified', LAST_MODIFIED) + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // within the stale-while-revalidate window, revalidated in the background + clock.tick(2000) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + + // wait for the background revalidation to complete + await setTimeout(100) + clock.tick(10) + await setTimeout(100) + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], LAST_MODIFIED) + }) + + test('falls back to the stored date value when there is no last-modified', async () => { + const clock = FakeTimers.install({ + now: new Date('2028-03-15T12:00:00.000Z').getTime() + }) + after(() => clock.uninstall()) + + // date header trailing the local clock, as with a skewed origin clock + const dateHeader = new Date(clock.now - 5000).toUTCString() + + const revalidationRequests = [] + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.sendDate = false + res.setHeader('Date', dateHeader) + res.setHeader('Cache-Control', 'public, max-age=10') + + if (req.headers['if-modified-since']) { + revalidationRequests.push(req.headers['if-modified-since']) + res.statusCode = 304 + res.end() + } else { + res.end('hello world') + } + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const url = `http://localhost:${server.address().port}` + + // initial request, cache the response + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + // let the response go stale (staleAt is based off of the date header) + clock.tick(6000) + + { + const res = await request(url, { dispatcher }) + strictEqual(await res.body.text(), 'hello world') + } + + strictEqual(revalidationRequests.length, 1) + strictEqual(revalidationRequests[0], dateHeader) + }) +})