Skip to content

Commit 16b5bfa

Browse files
authored
Add stale-while-revalidate pattern to Image Optimization API (#33735)
- Resolves #27208
1 parent eea3adc commit 16b5bfa

2 files changed

Lines changed: 97 additions & 21 deletions

File tree

packages/next/server/image-optimizer.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export async function imageOptimizer(
178178
const imagesDir = join(distDir, 'cache', 'images')
179179
const hashDir = join(imagesDir, hash)
180180
const now = Date.now()
181+
let staleWhileRevalidate = false
181182

182183
// If there're concurrent requests hitting the same resource and it's still
183184
// being optimized, wait before accessing the cache.
@@ -199,23 +200,27 @@ export async function imageOptimizer(
199200
const expireAt = Number(expireAtSt)
200201
const contentType = getContentType(extension)
201202
const fsPath = join(hashDir, file)
202-
if (now < expireAt) {
203-
const result = setResponseHeaders(
204-
req,
205-
res,
206-
url,
207-
etag,
208-
maxAge,
209-
contentType,
210-
isStatic,
211-
isDev
212-
)
213-
if (!result.finished) {
214-
createReadStream(fsPath).pipe(res)
215-
}
203+
const isFresh = now < expireAt
204+
const xCache = isFresh ? 'HIT' : 'STALE'
205+
const result = setResponseHeaders(
206+
req,
207+
res,
208+
url,
209+
etag,
210+
maxAge,
211+
contentType,
212+
isStatic,
213+
isDev,
214+
xCache
215+
)
216+
if (!result.finished) {
217+
createReadStream(fsPath).pipe(res)
218+
}
219+
if (isFresh) {
216220
return { finished: true }
217221
} else {
218222
await promises.unlink(fsPath)
223+
staleWhileRevalidate = true
219224
}
220225
}
221226
}
@@ -332,7 +337,8 @@ export async function imageOptimizer(
332337
upstreamType,
333338
upstreamBuffer,
334339
isStatic,
335-
isDev
340+
isDev,
341+
staleWhileRevalidate
336342
)
337343
return { finished: true }
338344
}
@@ -485,7 +491,8 @@ export async function imageOptimizer(
485491
contentType,
486492
optimizedBuffer,
487493
isStatic,
488-
isDev
494+
isDev,
495+
staleWhileRevalidate
489496
)
490497
} else {
491498
throw new Error('Unable to optimize buffer')
@@ -499,7 +506,8 @@ export async function imageOptimizer(
499506
upstreamType,
500507
upstreamBuffer,
501508
isStatic,
502-
isDev
509+
isDev,
510+
staleWhileRevalidate
503511
)
504512
}
505513

@@ -548,7 +556,8 @@ function setResponseHeaders(
548556
maxAge: number,
549557
contentType: string | null,
550558
isStatic: boolean,
551-
isDev: boolean
559+
isDev: boolean,
560+
xCache: 'MISS' | 'HIT' | 'STALE'
552561
) {
553562
res.setHeader('Vary', 'Accept')
554563
res.setHeader(
@@ -574,6 +583,7 @@ function setResponseHeaders(
574583
}
575584

576585
res.setHeader('Content-Security-Policy', `script-src 'none'; sandbox;`)
586+
res.setHeader('X-Nextjs-Cache', xCache)
577587

578588
return { finished: false }
579589
}
@@ -586,8 +596,13 @@ function sendResponse(
586596
contentType: string | null,
587597
buffer: Buffer,
588598
isStatic: boolean,
589-
isDev: boolean
599+
isDev: boolean,
600+
staleWhileRevalidate: boolean
590601
) {
602+
if (staleWhileRevalidate) {
603+
return
604+
}
605+
const xCache = 'MISS'
591606
const etag = getHash([buffer])
592607
const result = setResponseHeaders(
593608
req,
@@ -597,7 +612,8 @@ function sendResponse(
597612
maxAge,
598613
contentType,
599614
isStatic,
600-
isDev
615+
isDev,
616+
xCache
601617
)
602618
if (!result.finished) {
603619
res.end(buffer)

test/integration/image-optimizer/test/index.test.js

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,49 @@ function runTests({
500500
)
501501
await expectWidth(res, w)
502502
})
503+
504+
it('should use cache and stale-while-revalidate when query is the same for external image', async () => {
505+
await fs.remove(imagesDir)
506+
507+
const url = 'https://image-optimization-test.vercel.app/test.jpg'
508+
const query = { url, w, q: 39 }
509+
const opts = { headers: { accept: 'image/webp' } }
510+
511+
const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
512+
expect(res1.status).toBe(200)
513+
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
514+
expect(res1.headers.get('Content-Type')).toBe('image/webp')
515+
expect(res1.headers.get('Content-Disposition')).toBe(
516+
`inline; filename="test.webp"`
517+
)
518+
const json1 = await fsToJson(imagesDir)
519+
expect(Object.keys(json1).length).toBe(1)
520+
521+
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
522+
expect(res2.status).toBe(200)
523+
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
524+
expect(res2.headers.get('Content-Type')).toBe('image/webp')
525+
expect(res2.headers.get('Content-Disposition')).toBe(
526+
`inline; filename="test.webp"`
527+
)
528+
const json2 = await fsToJson(imagesDir)
529+
expect(json2).toStrictEqual(json1)
530+
531+
if (ttl) {
532+
// Wait until expired so we can confirm image is regenerated
533+
await waitFor(ttl * 1000)
534+
const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
535+
expect(res3.status).toBe(200)
536+
expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE')
537+
expect(res3.headers.get('Content-Type')).toBe('image/webp')
538+
expect(res3.headers.get('Content-Disposition')).toBe(
539+
`inline; filename="test.webp"`
540+
)
541+
const json3 = await fsToJson(imagesDir)
542+
expect(json3).not.toStrictEqual(json1)
543+
expect(Object.keys(json3).length).toBe(1)
544+
}
545+
})
503546
}
504547

505548
it('should fail when url has file protocol', async () => {
@@ -532,14 +575,15 @@ function runTests({
532575
})
533576
}
534577

535-
it('should use cached image file when parameters are the same', async () => {
578+
it('should use cache and stale-while-revalidate when query is the same for internal image', async () => {
536579
await fs.remove(imagesDir)
537580

538581
const query = { url: '/test.png', w, q: 80 }
539582
const opts = { headers: { accept: 'image/webp' } }
540583

541584
const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
542585
expect(res1.status).toBe(200)
586+
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
543587
expect(res1.headers.get('Content-Type')).toBe('image/webp')
544588
expect(res1.headers.get('Content-Disposition')).toBe(
545589
`inline; filename="test.webp"`
@@ -549,6 +593,7 @@ function runTests({
549593

550594
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
551595
expect(res2.status).toBe(200)
596+
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
552597
expect(res2.headers.get('Content-Type')).toBe('image/webp')
553598
expect(res2.headers.get('Content-Disposition')).toBe(
554599
`inline; filename="test.webp"`
@@ -561,6 +606,7 @@ function runTests({
561606
await waitFor(ttl * 1000)
562607
const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
563608
expect(res3.status).toBe(200)
609+
expect(res3.headers.get('X-Nextjs-Cache')).toBe('STALE')
564610
expect(res3.headers.get('Content-Type')).toBe('image/webp')
565611
expect(res3.headers.get('Content-Disposition')).toBe(
566612
`inline; filename="test.webp"`
@@ -579,6 +625,7 @@ function runTests({
579625

580626
const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
581627
expect(res1.status).toBe(200)
628+
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
582629
expect(res1.headers.get('Content-Type')).toBe('image/svg+xml')
583630
expect(res1.headers.get('Content-Disposition')).toBe(
584631
`inline; filename="test.svg"`
@@ -588,6 +635,7 @@ function runTests({
588635

589636
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
590637
expect(res2.status).toBe(200)
638+
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
591639
expect(res2.headers.get('Content-Type')).toBe('image/svg+xml')
592640
expect(res2.headers.get('Content-Disposition')).toBe(
593641
`inline; filename="test.svg"`
@@ -604,6 +652,7 @@ function runTests({
604652

605653
const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
606654
expect(res1.status).toBe(200)
655+
expect(res1.headers.get('X-Nextjs-Cache')).toBe('MISS')
607656
expect(res1.headers.get('Content-Type')).toBe('image/gif')
608657
expect(res1.headers.get('Content-Disposition')).toBe(
609658
`inline; filename="animated.gif"`
@@ -613,6 +662,7 @@ function runTests({
613662

614663
const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts)
615664
expect(res2.status).toBe(200)
665+
expect(res2.headers.get('X-Nextjs-Cache')).toBe('HIT')
616666
expect(res2.headers.get('Content-Type')).toBe('image/gif')
617667
expect(res2.headers.get('Content-Disposition')).toBe(
618668
`inline; filename="animated.gif"`
@@ -810,6 +860,16 @@ function runTests({
810860

811861
const json1 = await fsToJson(imagesDir)
812862
expect(Object.keys(json1).length).toBe(1)
863+
864+
const xCache1 = res1.headers.get('X-Nextjs-Cache')
865+
const xCache2 = res2.headers.get('X-Nextjs-Cache')
866+
if (xCache1 === 'HIT') {
867+
expect(xCache1).toBe('HIT')
868+
expect(xCache2).toBe('MISS')
869+
} else {
870+
expect(xCache1).toBe('MISS')
871+
expect(xCache2).toBe('HIT')
872+
}
813873
})
814874

815875
if (isDev || isSharp) {

0 commit comments

Comments
 (0)