Skip to content

Commit

Permalink
Add stale-while-revalidate pattern to Image Optimization API (verce…
Browse files Browse the repository at this point in the history
  • Loading branch information
styfle authored and natew committed Feb 16, 2022
1 parent 385b6e8 commit bc3d1fe
Show file tree
Hide file tree
Showing 2 changed files with 97 additions and 21 deletions.
56 changes: 36 additions & 20 deletions packages/next/server/image-optimizer.ts
Expand Up @@ -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.
Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -332,7 +337,8 @@ export async function imageOptimizer(
upstreamType,
upstreamBuffer,
isStatic,
isDev
isDev,
staleWhileRevalidate
)
return { finished: true }
}
Expand Down Expand Up @@ -485,7 +491,8 @@ export async function imageOptimizer(
contentType,
optimizedBuffer,
isStatic,
isDev
isDev,
staleWhileRevalidate
)
} else {
throw new Error('Unable to optimize buffer')
Expand All @@ -499,7 +506,8 @@ export async function imageOptimizer(
upstreamType,
upstreamBuffer,
isStatic,
isDev
isDev,
staleWhileRevalidate
)
}

Expand Down Expand Up @@ -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(
Expand All @@ -574,6 +583,7 @@ function setResponseHeaders(
}

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

return { finished: false }
}
Expand All @@ -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,
Expand All @@ -597,7 +612,8 @@ function sendResponse(
maxAge,
contentType,
isStatic,
isDev
isDev,
xCache
)
if (!result.finished) {
res.end(buffer)
Expand Down
62 changes: 61 additions & 1 deletion test/integration/image-optimizer/test/index.test.js
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -532,14 +575,15 @@ 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 }
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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit bc3d1fe

Please sign in to comment.