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

File tree

2 files changed

+97
-21
lines changed

2 files changed

+97
-21
lines changed

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy