Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add stale-while-revalidate pattern to Image Optimization API #33735

Merged
merged 8 commits into from Jan 27, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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