diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 7a0f7fe1ec18..0d603d546ab5 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -178,6 +178,7 @@ export async function imageOptimizer( const imagesDir = join(distDir, 'cache', 'images') const hashDir = join(imagesDir, hash) const now = Date.now() + let staleWhileRevalidate = false // If there're concurrent requests hitting the same resource and it's still // being optimized, wait before accessing the cache. @@ -199,23 +200,27 @@ export async function imageOptimizer( const expireAt = Number(expireAtSt) const contentType = getContentType(extension) const fsPath = join(hashDir, file) - if (now < expireAt) { - const result = setResponseHeaders( - req, - res, - url, - etag, - maxAge, - contentType, - isStatic, - isDev - ) - if (!result.finished) { - createReadStream(fsPath).pipe(res) - } + const isFresh = now < expireAt + const xCache = isFresh ? 'HIT' : 'STALE' + const result = setResponseHeaders( + req, + res, + url, + etag, + maxAge, + contentType, + isStatic, + isDev, + xCache + ) + if (!result.finished) { + createReadStream(fsPath).pipe(res) + } + if (isFresh) { return { finished: true } } else { await promises.unlink(fsPath) + staleWhileRevalidate = true } } } @@ -332,7 +337,8 @@ export async function imageOptimizer( upstreamType, upstreamBuffer, isStatic, - isDev + isDev, + staleWhileRevalidate ) return { finished: true } } @@ -485,7 +491,8 @@ export async function imageOptimizer( contentType, optimizedBuffer, isStatic, - isDev + isDev, + staleWhileRevalidate ) } else { throw new Error('Unable to optimize buffer') @@ -499,7 +506,8 @@ export async function imageOptimizer( upstreamType, upstreamBuffer, isStatic, - isDev + isDev, + staleWhileRevalidate ) } @@ -548,7 +556,8 @@ function setResponseHeaders( maxAge: number, contentType: string | null, isStatic: boolean, - isDev: boolean + isDev: boolean, + xCache: 'MISS' | 'HIT' | 'STALE' ) { res.setHeader('Vary', 'Accept') res.setHeader( @@ -574,6 +583,7 @@ function setResponseHeaders( } res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`) + res.setHeader('X-Nextjs-Cache', xCache) return { finished: false } } @@ -586,8 +596,13 @@ function sendResponse( contentType: string | null, buffer: Buffer, isStatic: boolean, - isDev: boolean + isDev: boolean, + staleWhileRevalidate: boolean ) { + if (staleWhileRevalidate) { + return + } + const xCache = 'MISS' const etag = getHash([buffer]) const result = setResponseHeaders( req, @@ -597,7 +612,8 @@ function sendResponse( maxAge, contentType, isStatic, - isDev + isDev, + xCache ) if (!result.finished) { res.end(buffer) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 26ea2bfc9d3a..2446330e528e 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -500,6 +500,49 @@ function runTests({ ) await expectWidth(res, w) }) + + it('should use cache and stale-while-revalidate when query is the same for external image', async () => { + await fs.remove(imagesDir) + + const url = 'https://image-optimization-test.vercel.app/test.jpg' + const query = { url, w, q: 39 } + const opts = { headers: { accept: 'image/webp' } } + + const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res1.status).toBe(200) + expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS') + expect(res1.headers.get('Content-Type')).toBe('image/webp') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) + const json1 = await fsToJson(imagesDir) + expect(Object.keys(json1).length).toBe(1) + + const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res2.status).toBe(200) + expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT') + expect(res2.headers.get('Content-Type')).toBe('image/webp') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) + const json2 = await fsToJson(imagesDir) + expect(json2).toStrictEqual(json1) + + if (ttl) { + // Wait until expired so we can confirm image is regenerated + await waitFor(ttl * 1000) + const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res3.status).toBe(200) + expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE') + expect(res3.headers.get('Content-Type')).toBe('image/webp') + expect(res3.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) + const json3 = await fsToJson(imagesDir) + expect(json3).not.toStrictEqual(json1) + expect(Object.keys(json3).length).toBe(1) + } + }) } it('should fail when url has file protocol', async () => { @@ -532,7 +575,7 @@ function runTests({ }) } - it('should use cached image file when parameters are the same', async () => { + it('should use cache and stale-while-revalidate when query is the same for internal image', async () => { await fs.remove(imagesDir) const query = { url: '/test.png', w, q: 80 } @@ -540,6 +583,7 @@ function runTests({ const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) + expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS') expect(res1.headers.get('Content-Type')).toBe('image/webp') expect(res1.headers.get('Content-Disposition')).toBe( `inline; filename="test.webp"` @@ -549,6 +593,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) + expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT') expect(res2.headers.get('Content-Type')).toBe('image/webp') expect(res2.headers.get('Content-Disposition')).toBe( `inline; filename="test.webp"` @@ -561,6 +606,7 @@ function runTests({ await waitFor(ttl * 1000) const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res3.status).toBe(200) + expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE') expect(res3.headers.get('Content-Type')).toBe('image/webp') expect(res3.headers.get('Content-Disposition')).toBe( `inline; filename="test.webp"` @@ -579,6 +625,7 @@ function runTests({ const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) + expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS') expect(res1.headers.get('Content-Type')).toBe('image/svg+xml') expect(res1.headers.get('Content-Disposition')).toBe( `inline; filename="test.svg"` @@ -588,6 +635,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) + expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT') expect(res2.headers.get('Content-Type')).toBe('image/svg+xml') expect(res2.headers.get('Content-Disposition')).toBe( `inline; filename="test.svg"` @@ -604,6 +652,7 @@ function runTests({ const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) + expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS') expect(res1.headers.get('Content-Type')).toBe('image/gif') expect(res1.headers.get('Content-Disposition')).toBe( `inline; filename="animated.gif"` @@ -613,6 +662,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) + expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT') expect(res2.headers.get('Content-Type')).toBe('image/gif') expect(res2.headers.get('Content-Disposition')).toBe( `inline; filename="animated.gif"` @@ -810,6 +860,16 @@ function runTests({ const json1 = await fsToJson(imagesDir) expect(Object.keys(json1).length).toBe(1) + + const xCache1 = res1.headers.get('X-Nextjs-Cache') + const xCache2 = res2.headers.get('X-Nextjs-Cache') + if (xCache1 === 'HIT') { + expect(xCache1).toBe('HIT') + expect(xCache2).toBe('MISS') + } else { + expect(xCache1).toBe('MISS') + expect(xCache2).toBe('HIT') + } }) if (isDev || isSharp) {