diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index 0bc3d9d9bda..b0449374fd4 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -9,6 +9,23 @@ const CacheRevalidationHandler = require('../handler/cache-revalidation-handler' const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js') const { AbortError } = require('../core/errors.js') +/** + * @param {(string | RegExp)[] | undefined} origins + * @param {string} name + */ +function assertCacheOrigins (origins, name) { + if (origins === undefined) return + if (!Array.isArray(origins)) { + throw new TypeError(`expected ${name} to be an array or undefined, got ${typeof origins}`) + } + for (let i = 0; i < origins.length; i++) { + const origin = origins[i] + if (typeof origin !== 'string' && !(origin instanceof RegExp)) { + throw new TypeError(`expected ${name}[${i}] to be a string or RegExp, got ${typeof origin}`) + } + } +} + const nop = () => {} /** @@ -372,7 +389,8 @@ module.exports = (opts = {}) => { store = new MemoryCacheStore(), methods = ['GET'], cacheByDefault = undefined, - type = 'shared' + type = 'shared', + origins = undefined } = opts if (typeof opts !== 'object' || opts === null) { @@ -381,6 +399,7 @@ module.exports = (opts = {}) => { assertCacheStore(store, 'opts.store') assertCacheMethods(methods, 'opts.methods') + assertCacheOrigins(origins, 'opts.origins') if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') { throw new TypeError(`expected opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`) @@ -406,6 +425,29 @@ module.exports = (opts = {}) => { return dispatch(opts, handler) } + // Check if origin is in whitelist + if (origins !== undefined) { + const requestOrigin = opts.origin.toString().toLowerCase() + let isAllowed = false + + for (let i = 0; i < origins.length; i++) { + const allowed = origins[i] + if (typeof allowed === 'string') { + if (allowed.toLowerCase() === requestOrigin) { + isAllowed = true + break + } + } else if (allowed.test(requestOrigin)) { + isAllowed = true + break + } + } + + if (!isAllowed) { + return dispatch(opts, handler) + } + } + opts = { ...opts, headers: normalizeHeaders(opts) diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index 820f97f93ae..ea4e8d153b7 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -1609,4 +1609,379 @@ describe('Cache Interceptor', () => { equal(requestsToOrigin, 1) // Still only one origin request } }) + + describe('origins option', () => { + test('caches request when origin matches string in whitelist', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: ['localhost'] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + + // Second request should be served from cache + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + }) + + test('skips caching when origin does not match string in whitelist', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('not cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: ['http://example.com'] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'not cached') + } + + // Second request should also hit origin (not cached) + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'not cached') + } + }) + + test('caches request when origin matches RegExp in whitelist', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: [/localhost/] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + + // Second request should be served from cache + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + }) + + test('skips caching when origin does not match RegExp in whitelist', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('not cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: [/example\.com/] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'not cached') + } + + // Second request should also hit origin (not cached) + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'not cached') + } + }) + + test('caches request when origin matches any entry in mixed array', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: ['http://other.com', /localhost/] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + + // Second request should be served from cache (matches RegExp) + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + }) + + test('caches all origins when origins option is undefined (default behavior)', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + + // Second request should be served from cache + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + }) + + test('caches nothing when origins is empty array', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('not cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: [] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'not cached') + } + + // Second request should also hit origin (not cached) + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'not cached') + } + }) + + test('throws TypeError when origins is not an array', async () => { + const { throws } = require('node:assert') + + throws( + () => interceptors.cache({ origins: 'http://example.com' }), + { + name: 'TypeError', + message: /expected opts\.origins to be an array or undefined/i + } + ) + }) + + test('throws TypeError when origins array contains invalid type', async () => { + const { throws } = require('node:assert') + + throws( + () => interceptors.cache({ origins: [123] }), + { + name: 'TypeError', + message: /expected opts\.origins\[0\] to be a string or RegExp/i + } + ) + }) + + test('string matching is case insensitive', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: ['LOCALHOST'] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + + // Second request should be served from cache (case insensitive match) + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'cached') + } + }) + + test('different hosts are treated as different origins', async () => { + let requestsToOrigin = 0 + const server = createServer({ joinDuplicateHeaders: true }, (_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 's-maxage=10') + res.end('not cached') + }).listen(0) + + after(() => server.close()) + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ + origins: ['example.com'] + })) + + after(() => client.close()) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // First request should hit origin + { + const res = await client.request(request) + equal(requestsToOrigin, 1) + strictEqual(await res.body.text(), 'not cached') + } + + // Second request should also hit origin (different host = different origin) + { + const res = await client.request(request) + equal(requestsToOrigin, 2) + strictEqual(await res.body.text(), 'not cached') + } + }) + }) }) diff --git a/test/types/cache-interceptor.test-d.ts b/test/types/cache-interceptor.test-d.ts index ce9543646cb..ee69e85e123 100644 --- a/test/types/cache-interceptor.test-d.ts +++ b/test/types/cache-interceptor.test-d.ts @@ -21,6 +21,16 @@ expectAssignable({ store }) expectAssignable({ methods: [] }) expectAssignable({ store, methods: ['GET'] }) +// origins option type tests +expectAssignable({ origins: undefined }) +expectAssignable({ origins: [] }) +expectAssignable({ origins: ['http://localhost'] }) +expectAssignable({ origins: [/localhost/] }) +expectAssignable({ origins: ['http://example.com', /localhost/] }) +expectNotAssignable({ origins: 'http://localhost' }) +expectNotAssignable({ origins: [123] }) +expectNotAssignable({ origins: [null] }) + expectAssignable({ statusCode: 200, statusMessage: 'OK', diff --git a/types/cache-interceptor.d.ts b/types/cache-interceptor.d.ts index 013e207e1d4..8588ccdcb35 100644 --- a/types/cache-interceptor.d.ts +++ b/types/cache-interceptor.d.ts @@ -39,6 +39,12 @@ declare namespace CacheHandler { */ type?: 'shared' | 'private' + /** + * Array of origins to cache. Only requests to these origins will be cached. + * Supports strings (case insensitive) and RegExp patterns. + * @default undefined (cache all origins) + */ + origins?: (string | RegExp)[] } export interface CacheControlDirectives {