From 8daf0176550db4dfa2fc44c9c8fe2f466adc0d84 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 27 Jan 2022 09:18:32 -0500 Subject: [PATCH 1/5] Add `stale-while-revalidate` pattern to Image Optimization API --- packages/next/server/image-optimizer.ts | 88 ++++++++++++++----------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index de5a6315731309a..a5d66c1c8335c98 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 serveStale = false // If there're concurrent requests hitting the same resource and it's still // being optimized, wait before accessing the cache. @@ -199,23 +200,24 @@ 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 result = setResponseHeaders( + req, + res, + url, + etag, + maxAge, + contentType, + isStatic, + isDev + ) + if (!result.finished) { + createReadStream(fsPath).pipe(res) + } + if (isFresh) { return { finished: true } } else { - await promises.unlink(fsPath) + serveStale = true } } } @@ -316,16 +318,18 @@ export async function imageOptimizer( expireAt, upstreamBuffer ) - sendResponse( - req, - res, - url, - maxAge, - upstreamType, - upstreamBuffer, - isStatic, - isDev - ) + if (!serveStale) { + sendResponse( + req, + res, + url, + maxAge, + upstreamType, + upstreamBuffer, + isStatic, + isDev + ) + } return { finished: true } } @@ -470,30 +474,34 @@ export async function imageOptimizer( expireAt, optimizedBuffer ) + if (!serveStale) { + sendResponse( + req, + res, + url, + maxAge, + contentType, + optimizedBuffer, + isStatic, + isDev + ) + } + } else { + throw new Error('Unable to optimize buffer') + } + } catch (error) { + if (!serveStale) { sendResponse( req, res, url, maxAge, - contentType, - optimizedBuffer, + upstreamType, + upstreamBuffer, isStatic, isDev ) - } else { - throw new Error('Unable to optimize buffer') } - } catch (error) { - sendResponse( - req, - res, - url, - maxAge, - upstreamType, - upstreamBuffer, - isStatic, - isDev - ) } return { finished: true } From e3e5abcc0684dd257bb53a7de3d4cc010261c1f9 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 27 Jan 2022 11:07:23 -0500 Subject: [PATCH 2/5] Revise variable names --- packages/next/server/image-optimizer.ts | 84 ++++++++++++++----------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index a5d66c1c8335c98..dbe61152ee114f3 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -178,7 +178,7 @@ export async function imageOptimizer( const imagesDir = join(distDir, 'cache', 'images') const hashDir = join(imagesDir, hash) const now = Date.now() - let serveStale = false + let staleWhileRevalidate = false // If there're concurrent requests hitting the same resource and it's still // being optimized, wait before accessing the cache. @@ -201,6 +201,7 @@ export async function imageOptimizer( const contentType = getContentType(extension) const fsPath = join(hashDir, file) const isFresh = now < expireAt + const xCache = isFresh ? 'HIT' : 'STALE' const result = setResponseHeaders( req, res, @@ -209,7 +210,8 @@ export async function imageOptimizer( maxAge, contentType, isStatic, - isDev + isDev, + xCache ) if (!result.finished) { createReadStream(fsPath).pipe(res) @@ -217,7 +219,8 @@ export async function imageOptimizer( if (isFresh) { return { finished: true } } else { - serveStale = true + await promises.unlink(fsPath) + staleWhileRevalidate = true } } } @@ -318,18 +321,17 @@ export async function imageOptimizer( expireAt, upstreamBuffer ) - if (!serveStale) { - sendResponse( - req, - res, - url, - maxAge, - upstreamType, - upstreamBuffer, - isStatic, - isDev - ) - } + sendResponse( + req, + res, + url, + maxAge, + upstreamType, + upstreamBuffer, + isStatic, + isDev, + staleWhileRevalidate + ) return { finished: true } } @@ -474,34 +476,32 @@ export async function imageOptimizer( expireAt, optimizedBuffer ) - if (!serveStale) { - sendResponse( - req, - res, - url, - maxAge, - contentType, - optimizedBuffer, - isStatic, - isDev - ) - } - } else { - throw new Error('Unable to optimize buffer') - } - } catch (error) { - if (!serveStale) { sendResponse( req, res, url, maxAge, - upstreamType, - upstreamBuffer, + contentType, + optimizedBuffer, isStatic, - isDev + isDev, + staleWhileRevalidate ) + } else { + throw new Error('Unable to optimize buffer') } + } catch (error) { + sendResponse( + req, + res, + url, + maxAge, + upstreamType, + upstreamBuffer, + isStatic, + isDev, + staleWhileRevalidate + ) } return { finished: true } @@ -549,7 +549,8 @@ function setResponseHeaders( maxAge: number, contentType: string | null, isStatic: boolean, - isDev: boolean + isDev: boolean, + xCache: 'MISS' | 'HIT' | 'STALE' ) { res.setHeader('Vary', 'Accept') res.setHeader( @@ -575,6 +576,7 @@ function setResponseHeaders( } res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`) + res.setHeader('X-Nextjs-Cache', xCache) return { finished: false } } @@ -587,8 +589,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, @@ -598,7 +605,8 @@ function sendResponse( maxAge, contentType, isStatic, - isDev + isDev, + xCache ) if (!result.finished) { res.end(buffer) From c1dc0c6ff60316e68dd771b0c1f542f73ce922e8 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 27 Jan 2022 11:07:29 -0500 Subject: [PATCH 3/5] Add tests --- .../image-optimizer/test/index.test.js | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 89f9cd36d7fff31..d3ffe6fcc8432fa 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"` From 34253f3ce8caeb57c7c7b63b02ef56ff4503dc20 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 27 Jan 2022 11:26:51 -0500 Subject: [PATCH 4/5] Add more assertions --- .../integration/image-optimizer/test/index.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index d3ffe6fcc8432fa..8b85feab9019ff8 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -625,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"` @@ -634,6 +635,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) + expect(res1.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"` @@ -650,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"` @@ -659,6 +662,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) + expect(res1.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"` @@ -848,6 +852,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) { From 80c2479465786862006123cf2404aa2a207df4e9 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 27 Jan 2022 13:56:33 -0500 Subject: [PATCH 5/5] Fix typo --- test/integration/image-optimizer/test/index.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 6ad3c8c49d9ab44..2446330e528e5d1 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -635,7 +635,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) - expect(res1.headers.get('X-Nextjs-Cache')).toBe('HIT') + 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"` @@ -662,7 +662,7 @@ function runTests({ const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) - expect(res1.headers.get('X-Nextjs-Cache')).toBe('HIT') + 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"`