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 etag header to optimized image response #18986

Merged
merged 9 commits into from
Nov 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 4 additions & 21 deletions packages/next/next-server/server/api-utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { IncomingMessage, ServerResponse } from 'http'
import { parse } from 'next/dist/compiled/content-type'
import { CookieSerializeOptions } from 'next/dist/compiled/cookie'
import generateETag from 'etag'
import fresh from 'next/dist/compiled/fresh'
import getRawBody from 'raw-body'
import { PageConfig } from 'next/types'
import { Stream } from 'stream'
import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils'
import { decryptWithSecret, encryptWithSecret } from './crypto-utils'
import { interopDefault } from './load-components'
import { Params } from './router'
import { sendEtagResponse } from './send-payload'
import generateETag from 'etag'

export type NextApiRequestCookies = { [key: string]: string }
export type NextApiRequestQuery = { [key: string]: string | string[] }
Expand Down Expand Up @@ -216,23 +216,6 @@ export function redirect(
return res
}

function sendEtagResponse(
req: NextApiRequest,
res: NextApiResponse,
body: string | Buffer
): boolean {
const etag = generateETag(body)

if (fresh(req.headers, { etag })) {
res.statusCode = 304
res.end()
return true
}

res.setHeader('ETag', etag)
return false
}

/**
* Send `any` body to response
* @param req request object
Expand Down Expand Up @@ -261,8 +244,8 @@ export function sendData(

const isJSONLike = ['object', 'number', 'boolean'].includes(typeof body)
const stringifiedBody = isJSONLike ? JSON.stringify(body) : body

if (sendEtagResponse(req, res, stringifiedBody)) {
const etag = generateETag(stringifiedBody)
if (sendEtagResponse(req, res, etag)) {
return
}

Expand Down
61 changes: 40 additions & 21 deletions packages/next/next-server/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileExists } from '../../lib/file-exists'
// @ts-ignore no types for is-animated
import isAnimated from 'next/dist/compiled/is-animated'
import Stream from 'stream'
import { sendEtagResponse } from './send-payload'

let sharp: typeof import('sharp')
//const AVIF = 'image/avif'
Expand All @@ -18,7 +19,7 @@ const PNG = 'image/png'
const JPEG = 'image/jpeg'
const GIF = 'image/gif'
const SVG = 'image/svg+xml'
const CACHE_VERSION = 1
const CACHE_VERSION = 2
const MODERN_TYPES = [/* AVIF, */ WEBP]
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
const VECTOR_TYPES = [SVG]
Expand Down Expand Up @@ -143,17 +144,22 @@ export async function imageOptimizer(
if (await fileExists(hashDir, 'directory')) {
const files = await promises.readdir(hashDir)
for (let file of files) {
const [filename, extension] = file.split('.')
const expireAt = Number(filename)
const [prefix, etag, extension] = file.split('.')
const expireAt = Number(prefix)
const contentType = getContentType(extension)
const fsPath = join(hashDir, file)
if (now < expireAt) {
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
if (sendEtagResponse(req, res, etag)) {
return { finished: true }
}
if (contentType) {
res.setHeader('Content-Type', contentType)
}
createReadStream(join(hashDir, file)).pipe(res)
createReadStream(fsPath).pipe(res)
return { finished: true }
} else {
await promises.unlink(join(hashDir, file))
await promises.unlink(fsPath)
}
}
}
Expand Down Expand Up @@ -223,8 +229,7 @@ export async function imageOptimizer(
const animate =
ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)
if (vector || animate) {
res.setHeader('Content-Type', upstreamType)
res.end(upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer)
return { finished: true }
}
}
Expand All @@ -248,10 +253,8 @@ export async function imageOptimizer(
if (error.code === 'MODULE_NOT_FOUND') {
error.message += '\n\nLearn more: https://err.sh/next.js/install-sharp'
server.logError(error)
if (upstreamType) {
res.setHeader('Content-Type', upstreamType)
}
res.end(upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer)
return { finished: true }
}
throw error
}
Expand Down Expand Up @@ -281,30 +284,46 @@ export async function imageOptimizer(
const optimizedBuffer = await transformer.toBuffer()
await promises.mkdir(hashDir, { recursive: true })
const extension = getExtension(contentType)
const filename = join(hashDir, `${expireAt}.${extension}`)
const etag = getHash([optimizedBuffer])
const filename = join(hashDir, `${expireAt}.${etag}.${extension}`)
await promises.writeFile(filename, optimizedBuffer)
res.setHeader('Content-Type', contentType)
res.end(optimizedBuffer)
sendResponse(req, res, contentType, optimizedBuffer)
} catch (error) {
server.logError(error)
if (upstreamType) {
res.setHeader('Content-Type', upstreamType)
}
res.end(upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer)
}

return { finished: true }
}

function sendResponse(
req: IncomingMessage,
res: ServerResponse,
contentType: string | null,
buffer: Buffer
) {
const etag = getHash([buffer])
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kodiakhq @Timer

I don't understand this line - why is the cache max-age set to 0? Does this affect images?

I thought images would be 60 seconds as per https://nextjs.org/docs/basic-features/image-optimization#caching

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's a bit confusing, it caught me off guard as well.

What I get from it is that there are two different forms of caching: on the process hosting NextJS (server) and browser (client) side.

Here, the client side is instructed to always check if there's a newer version of the image available. Which is necessary with the introduction of etags to allow the NextJS process to invalidate images at any time.

On the server side, NextJS has its own cache for the generated images. The cached image will be served according to the max-age header value when retrieving the image, defaulting to 60 seconds if the header was not supplied.

if (sendEtagResponse(req, res, etag)) {
return
}
if (contentType) {
res.setHeader('Content-Type', contentType)
}
res.end(buffer)
}

function getSupportedMimeType(options: string[], accept = ''): string {
const mimeType = mediaType(accept, options)
return accept.includes(mimeType) ? mimeType : ''
}

function getHash(items: (string | number | undefined)[]) {
function getHash(items: (string | number | Buffer)[]) {
const hash = createHash('sha256')
for (let item of items) {
hash.update(String(item))
if (typeof item === 'number') hash.update(String(item))
else {
hash.update(item)
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-')
Expand Down
33 changes: 25 additions & 8 deletions packages/next/next-server/server/send-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,10 @@ export function sendPayload(
}

const etag = generateEtags ? generateETag(payload) : undefined

if (fresh(req.headers, { etag })) {
res.statusCode = 304
res.end()
if (sendEtagResponse(req, res, etag)) {
return
}

if (etag) {
res.setHeader('ETag', etag)
}

if (!res.getHeader('Content-Type')) {
res.setHeader(
'Content-Type',
Expand Down Expand Up @@ -72,3 +65,27 @@ export function sendPayload(
}
res.end(req.method === 'HEAD' ? null : payload)
}

export function sendEtagResponse(
req: IncomingMessage,
res: ServerResponse,
etag: string | undefined
): boolean {
if (etag) {
/**
* The server generating a 304 response MUST generate any of the
* following header fields that would have been sent in a 200 (OK)
* response to the same request: Cache-Control, Content-Location, Date,
* ETag, Expires, and Vary. https://tools.ietf.org/html/rfc7232#section-4.1
*/
res.setHeader('ETag', etag)
}

if (fresh(req.headers, { etag })) {
res.statusCode = 304
res.end()
return true
}

return false
}
Loading