diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 06c81e7b03203..cce62c11a68b8 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -136,9 +136,11 @@ Images are optimized dynamically upon request and stored in the `/cache The expiration (or rather Max Age) is defined by the upstream server's `Cache-Control` header. -If `s-maxage` is found in `Cache-Control`, it is used. If no `s-maxage` is found, then `max-age` is used. If no `max-age` is found, then 60 seconds is used. +If `s-maxage` is found in `Cache-Control`, it is used. If no `s-maxage` is found, then `max-age` is used. If no `max-age` is found, then [`minimumCacheTTL`](#minimum-cache-ttl) is used. -You can configure [`deviceSizes`](#device-sizes) and [`imageSizes`](#device-sizes) to reduce the total number of possible generated images. +You can configure [`minimumCacheTTL`](#minimum-cache-ttl) to increase the cache duration when the upstream image does not include `max-age`. + +You can also configure [`deviceSizes`](#device-sizes) and [`imageSizes`](#device-sizes) to reduce the total number of possible generated images. ## Advanced @@ -172,6 +174,18 @@ module.exports = { } ``` +### Minimum Cache TTL + +You can configure the time to live (TTL) in seconds for cached optimized images. This will configure the server's image cache as well as the `Cache-Control` header sent to the browser. This is a global setting that will affect all images. In most cases, its better to use a [Static Image Import](#image-Imports) which will handle hashing file contents and caching the file. You can also configure the `Cache-Control` header on an individual upstream image instead. + +```js +module.exports = { + images: { + minimumCacheTTL: 60, + }, +} +``` + ### Disable Static Imports The default behavior allows you to import static files such as `import icon from './icon.png` and then pass that to the `src` property. diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index c060c62728da3..febbc5ee4adad 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -18,8 +18,12 @@ module.exports = { // limit of 50 domains values domains: [], path: '/_next/image', - // loader can be 'default', 'imgix', 'cloudinary', or 'akamai' + // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom' loader: 'default', + // disable static imports for image files + disableStaticImages: false, + // minimumCacheTTL is in seconds, must be integer 0 or more + minimumCacheTTL: 60, }, } ``` diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 2c170b1bc72e2..9772635518865 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -291,6 +291,17 @@ function assignDefaults(userConfig: { [key: string]: any }) { if (images.path === imageConfigDefault.path && result.basePath) { images.path = `${result.basePath}${images.path}` } + + if ( + images.minimumCacheTTL && + (!Number.isInteger(images.minimumCacheTTL) || images.minimumCacheTTL < 0) + ) { + throw new Error( + `Specified images.minimumCacheTTL should be an integer 0 or more + ', ' + )}), received (${images.minimumCacheTTL}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } } if (result.i18n) { diff --git a/packages/next/server/image-config.ts b/packages/next/server/image-config.ts index d3f534fca73ec..e978a419c9c8e 100644 --- a/packages/next/server/image-config.ts +++ b/packages/next/server/image-config.ts @@ -15,6 +15,7 @@ export type ImageConfig = { path: string domains?: string[] disableStaticImages?: boolean + minimumCacheTTL?: number } export const imageConfigDefault: ImageConfig = { @@ -24,4 +25,5 @@ export const imageConfigDefault: ImageConfig = { loader: 'default', domains: [], disableStaticImages: false, + minimumCacheTTL: 60, } diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 2b3dfb2ccaac0..6a89d560dce84 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -39,7 +39,13 @@ export async function imageOptimizer( isDev = false ) { const imageData: ImageConfig = nextConfig.images || imageConfigDefault - const { deviceSizes = [], imageSizes = [], domains = [], loader } = imageData + const { + deviceSizes = [], + imageSizes = [], + domains = [], + loader, + minimumCacheTTL = 60, + } = imageData if (loader !== 'default') { await server.render404(req, res, parsedUrl) @@ -206,7 +212,10 @@ export async function imageOptimizer( upstreamType = detectContentType(upstreamBuffer) || upstreamRes.headers.get('Content-Type') - maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control')) + maxAge = getMaxAge( + upstreamRes.headers.get('Cache-Control'), + minimumCacheTTL + ) } else { try { const resBuffers: Buffer[] = [] @@ -261,7 +270,7 @@ export async function imageOptimizer( upstreamBuffer = Buffer.concat(resBuffers) upstreamType = detectContentType(upstreamBuffer) || mockRes.getHeader('Content-Type') - maxAge = getMaxAge(mockRes.getHeader('Cache-Control')) + maxAge = getMaxAge(mockRes.getHeader('Cache-Control'), minimumCacheTTL) } catch (err) { res.statusCode = 500 res.end('"url" parameter is valid but upstream response is invalid') @@ -529,8 +538,7 @@ export function detectContentType(buffer: Buffer) { return null } -export function getMaxAge(str: string | null): number { - const minimum = 60 +export function getMaxAge(str: string | null, minimumCacheTTL: number): number { const map = parseCacheControl(str) if (map) { let age = map.get('s-maxage') || map.get('max-age') || '' @@ -539,8 +547,8 @@ export function getMaxAge(str: string | null): number { } const n = parseInt(age, 10) if (!isNaN(n)) { - return Math.max(n, minimum) + return Math.max(n, minimumCacheTTL) } } - return minimum + return minimumCacheTTL } diff --git a/test/integration/image-optimizer/test/get-max-age.test.js b/test/integration/image-optimizer/test/get-max-age.test.js index c842ba9fd0aec..b4cd38371ea79 100644 --- a/test/integration/image-optimizer/test/get-max-age.test.js +++ b/test/integration/image-optimizer/test/get-max-age.test.js @@ -3,42 +3,42 @@ import { getMaxAge } from '../../../../packages/next/dist/server/image-optimizer describe('getMaxAge', () => { it('should return default when no cache-control provided', () => { - expect(getMaxAge()).toBe(60) + expect(getMaxAge(undefined, 60)).toBe(60) }) it('should return default when cache-control is null', () => { - expect(getMaxAge(null)).toBe(60) + expect(getMaxAge(null, 60)).toBe(60) }) it('should return default when cache-control is empty string', () => { - expect(getMaxAge('')).toBe(60) + expect(getMaxAge('', 60)).toBe(60) }) it('should return default when cache-control max-age is less than default', () => { - expect(getMaxAge('max-age=30')).toBe(60) + expect(getMaxAge('max-age=30', 60)).toBe(60) }) it('should return default when cache-control max-age is not a number', () => { - expect(getMaxAge('max-age=foo')).toBe(60) + expect(getMaxAge('max-age=foo', 60)).toBe(60) }) it('should return default when cache-control is no-cache', () => { - expect(getMaxAge('no-cache')).toBe(60) + expect(getMaxAge('no-cache', 60)).toBe(60) }) it('should return cache-control max-age lowercase', () => { - expect(getMaxAge('max-age=9999')).toBe(9999) + expect(getMaxAge('max-age=9999', 60)).toBe(9999) }) it('should return cache-control MAX-AGE uppercase', () => { - expect(getMaxAge('MAX-AGE=9999')).toBe(9999) + expect(getMaxAge('MAX-AGE=9999', 60)).toBe(9999) }) it('should return cache-control s-maxage lowercase', () => { - expect(getMaxAge('s-maxage=9999')).toBe(9999) + expect(getMaxAge('s-maxage=9999', 60)).toBe(9999) }) it('should return cache-control S-MAXAGE', () => { - expect(getMaxAge('S-MAXAGE=9999')).toBe(9999) + expect(getMaxAge('S-MAXAGE=9999', 60)).toBe(9999) }) it('should return cache-control s-maxage with spaces', () => { - expect(getMaxAge('public, max-age=5555, s-maxage=9999')).toBe(9999) + expect(getMaxAge('public, max-age=5555, s-maxage=9999', 60)).toBe(9999) }) it('should return cache-control s-maxage without spaces', () => { - expect(getMaxAge('public,s-maxage=9999,max-age=5555')).toBe(9999) + expect(getMaxAge('public,s-maxage=9999,max-age=5555', 60)).toBe(9999) }) it('should return cache-control for a quoted value', () => { - expect(getMaxAge('public, s-maxage="9999", max-age="5555"')).toBe(9999) + expect(getMaxAge('public, s-maxage="9999", max-age="5555"', 60)).toBe(9999) }) }) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 358505c075dd9..8ad03a97c7a9d 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -45,7 +45,7 @@ async function expectWidth(res, w) { expect(d.width).toBe(w) } -function runTests({ w, isDev, domains }) { +function runTests({ w, isDev, domains = [], ttl = 60 }) { it('should return home page', async () => { const res = await fetchViaHTTP(appPort, '/', null, {}) expect(await res.text()).toMatch(/Image Optimizer Home/m) @@ -57,7 +57,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('image/gif') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -70,7 +70,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('image/png') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -83,7 +83,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -97,7 +97,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('image/svg+xml') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) // SVG is compressible so will have accept-encoding set from // compression @@ -118,7 +118,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('image/x-icon') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/) expect(res.headers.get('etag')).toBeTruthy() @@ -139,7 +139,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('image/jpeg') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -154,7 +154,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('image/png') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -252,7 +252,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -266,7 +266,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/png') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -280,7 +280,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/png') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -294,7 +294,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/gif') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -308,7 +308,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/tiff') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -324,7 +324,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -340,7 +340,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -361,7 +361,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -464,7 +464,7 @@ function runTests({ w, isDev, domains }) { expect(res1.status).toBe(200) expect(res1.headers.get('Content-Type')).toBe('image/webp') expect(res1.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res1.headers.get('Vary')).toBe('Accept') const etag = res1.headers.get('Etag') @@ -477,7 +477,7 @@ function runTests({ w, isDev, domains }) { expect(res2.headers.get('Content-Type')).toBeFalsy() expect(res2.headers.get('Etag')).toBe(etag) expect(res2.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res2.headers.get('Vary')).toBe('Accept') expect((await res2.buffer()).length).toBe(0) @@ -487,7 +487,7 @@ function runTests({ w, isDev, domains }) { expect(res3.status).toBe(200) expect(res3.headers.get('Content-Type')).toBe('image/webp') expect(res3.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res3.headers.get('Vary')).toBe('Accept') expect(res3.headers.get('Etag')).toBeTruthy() @@ -505,7 +505,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/bmp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) // bmp is compressible so will have accept-encoding set from // compression @@ -523,7 +523,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/webp') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() @@ -539,7 +539,7 @@ function runTests({ w, isDev, domains }) { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/png') expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=${isDev ? 0 : 60}, must-revalidate` + `public, max-age=${isDev ? 0 : ttl}, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') @@ -808,6 +808,28 @@ describe('Image Optimizer', () => { runTests({ w: size, isDev: true, domains }) }) + describe('dev support for minimumCacheTTL', () => { + const size = 96 // defaults defined in server/config.ts + const ttl = 500 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + minimumCacheTTL: ttl, + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true, ttl }) + }) + describe('Server support w/o next.config.js', () => { const size = 384 // defaults defined in server/config.ts beforeAll(async () => { @@ -846,6 +868,29 @@ describe('Image Optimizer', () => { runTests({ w: size, isDev: false, domains }) }) + describe('Server support for minimumCacheTTL', () => { + const size = 96 // defaults defined in server/config.ts + const ttl = 900000 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + minimumCacheTTL: ttl, + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false, ttl }) + }) + describe('Serverless support with next.config.js', () => { const size = 256 beforeAll(async () => {