Skip to content

Commit 2373320

Browse files
authored
Add upstream max-age to optimized image (#26739)
This solves the main use case from Issue #19914. Previously, we would set the `Cache-Control` header to a constant and rely on the server cache. This would mean the browser would always request the image and the server could response with 304 Not Modified to omit the response body. This PR changes the behavior such that the `max-age` will propagate from the upstream server to the Next.js Image Optimization Server and allow browser caching. ("upstream" meaning external server or just an internal route to an image) This PR does not change the `max-age` for static imports which will remain `public, max-age=315360000, immutable`. #### Pros: - Fewer HTTP requests after initial browser visit - User configurable `max-age` via the upstream image `Cache-Control` header #### Cons: - ~~Might be annoying for `next dev` when modifying a source image~~ (solved: use `max-age=0` for dev) - Might cause browser to cache longer than expected (up to 2x longer than the server cache if requested in the last second before expiration) ## Bug - [x] Related issues linked using `fixes #number`
1 parent b046a05 commit 2373320

File tree

3 files changed

+146
-85
lines changed

3 files changed

+146
-85
lines changed

packages/next/server/image-optimizer.ts

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const PNG = 'image/png'
2222
const JPEG = 'image/jpeg'
2323
const GIF = 'image/gif'
2424
const SVG = 'image/svg+xml'
25-
const CACHE_VERSION = 2
25+
const CACHE_VERSION = 3
2626
const MODERN_TYPES = [/* AVIF, */ WEBP]
2727
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
2828
const VECTOR_TYPES = [SVG]
@@ -35,7 +35,8 @@ export async function imageOptimizer(
3535
res: ServerResponse,
3636
parsedUrl: UrlWithParsedQuery,
3737
nextConfig: NextConfig,
38-
distDir: string
38+
distDir: string,
39+
isDev = false
3940
) {
4041
const imageData: ImageConfig = nextConfig.images || imageConfigDefault
4142
const { deviceSizes = [], imageSizes = [], domains = [], loader } = imageData
@@ -158,24 +159,24 @@ export async function imageOptimizer(
158159
if (await fileExists(hashDir, 'directory')) {
159160
const files = await promises.readdir(hashDir)
160161
for (let file of files) {
161-
const [prefix, etag, extension] = file.split('.')
162-
const expireAt = Number(prefix)
162+
const [maxAgeStr, expireAtSt, etag, extension] = file.split('.')
163+
const maxAge = Number(maxAgeStr)
164+
const expireAt = Number(expireAtSt)
163165
const contentType = getContentType(extension)
164166
const fsPath = join(hashDir, file)
165167
if (now < expireAt) {
166-
res.setHeader(
167-
'Cache-Control',
168-
isStatic
169-
? 'public, max-age=315360000, immutable'
170-
: 'public, max-age=0, must-revalidate'
168+
const result = setResponseHeaders(
169+
req,
170+
res,
171+
etag,
172+
maxAge,
173+
contentType,
174+
isStatic,
175+
isDev
171176
)
172-
if (sendEtagResponse(req, res, etag)) {
173-
return { finished: true }
177+
if (!result.finished) {
178+
createReadStream(fsPath).pipe(res)
174179
}
175-
if (contentType) {
176-
res.setHeader('Content-Type', contentType)
177-
}
178-
createReadStream(fsPath).pipe(res)
179180
return { finished: true }
180181
} else {
181182
await promises.unlink(fsPath)
@@ -271,8 +272,22 @@ export async function imageOptimizer(
271272
const animate =
272273
ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)
273274
if (vector || animate) {
274-
await writeToCacheDir(hashDir, upstreamType, expireAt, upstreamBuffer)
275-
sendResponse(req, res, upstreamType, upstreamBuffer, isStatic)
275+
await writeToCacheDir(
276+
hashDir,
277+
upstreamType,
278+
maxAge,
279+
expireAt,
280+
upstreamBuffer
281+
)
282+
sendResponse(
283+
req,
284+
res,
285+
maxAge,
286+
upstreamType,
287+
upstreamBuffer,
288+
isStatic,
289+
isDev
290+
)
276291
return { finished: true }
277292
}
278293

@@ -342,13 +357,35 @@ export async function imageOptimizer(
342357
}
343358

344359
if (optimizedBuffer) {
345-
await writeToCacheDir(hashDir, contentType, expireAt, optimizedBuffer)
346-
sendResponse(req, res, contentType, optimizedBuffer, isStatic)
360+
await writeToCacheDir(
361+
hashDir,
362+
contentType,
363+
maxAge,
364+
expireAt,
365+
optimizedBuffer
366+
)
367+
sendResponse(
368+
req,
369+
res,
370+
maxAge,
371+
contentType,
372+
optimizedBuffer,
373+
isStatic,
374+
isDev
375+
)
347376
} else {
348377
throw new Error('Unable to optimize buffer')
349378
}
350379
} catch (error) {
351-
sendResponse(req, res, upstreamType, upstreamBuffer, isStatic)
380+
sendResponse(
381+
req,
382+
res,
383+
maxAge,
384+
upstreamType,
385+
upstreamBuffer,
386+
isStatic,
387+
isDev
388+
)
352389
}
353390

354391
return { finished: true }
@@ -362,37 +399,64 @@ export async function imageOptimizer(
362399
async function writeToCacheDir(
363400
dir: string,
364401
contentType: string,
402+
maxAge: number,
365403
expireAt: number,
366404
buffer: Buffer
367405
) {
368406
await promises.mkdir(dir, { recursive: true })
369407
const extension = getExtension(contentType)
370408
const etag = getHash([buffer])
371-
const filename = join(dir, `${expireAt}.${etag}.${extension}`)
409+
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`)
372410
await promises.writeFile(filename, buffer)
373411
}
374412

375-
function sendResponse(
413+
function setResponseHeaders(
376414
req: IncomingMessage,
377415
res: ServerResponse,
416+
etag: string,
417+
maxAge: number,
378418
contentType: string | null,
379-
buffer: Buffer,
380-
isStatic: boolean
419+
isStatic: boolean,
420+
isDev: boolean
381421
) {
382-
const etag = getHash([buffer])
383422
res.setHeader(
384423
'Cache-Control',
385424
isStatic
386425
? 'public, max-age=315360000, immutable'
387-
: 'public, max-age=0, must-revalidate'
426+
: `public, max-age=${isDev ? 0 : maxAge}, must-revalidate`
388427
)
389428
if (sendEtagResponse(req, res, etag)) {
390-
return
429+
// already called res.end() so we're finished
430+
return { finished: true }
391431
}
392432
if (contentType) {
393433
res.setHeader('Content-Type', contentType)
394434
}
395-
res.end(buffer)
435+
return { finished: false }
436+
}
437+
438+
function sendResponse(
439+
req: IncomingMessage,
440+
res: ServerResponse,
441+
maxAge: number,
442+
contentType: string | null,
443+
buffer: Buffer,
444+
isStatic: boolean,
445+
isDev: boolean
446+
) {
447+
const etag = getHash([buffer])
448+
const result = setResponseHeaders(
449+
req,
450+
res,
451+
etag,
452+
maxAge,
453+
contentType,
454+
isStatic,
455+
isDev
456+
)
457+
if (!result.finished) {
458+
res.end(buffer)
459+
}
396460
}
397461

398462
function getSupportedMimeType(options: string[], accept = ''): string {

packages/next/server/next-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,8 @@ export default class Server {
789789
res,
790790
parsedUrl,
791791
server.nextConfig,
792-
server.distDir
792+
server.distDir,
793+
this.renderOpts.dev
793794
),
794795
},
795796
{

0 commit comments

Comments
 (0)