Skip to content

Commit

Permalink
Add support for Image Optimizer (#17749)
Browse files Browse the repository at this point in the history
Co-authored-by: JJ Kasper <jj@jjsweb.site>
  • Loading branch information
styfle and ijjk committed Oct 16, 2020
1 parent f0ead09 commit d3741d5
Show file tree
Hide file tree
Showing 19 changed files with 862 additions and 4 deletions.
15 changes: 15 additions & 0 deletions errors/install-sharp.md
@@ -0,0 +1,15 @@
# Install `sharp` to Use Built-In Image Optimization

#### Why This Error Occurred

Using Next.js' built-in Image Optimization requires that you bring your own version of `sharp`.

#### Possible Ways to Fix It

Please install the `sharp` package in your project.

```bash
npm i sharp
# or
yarn add sharp
```
10 changes: 10 additions & 0 deletions packages/next/build/index.ts
Expand Up @@ -29,6 +29,7 @@ import {
CLIENT_STATIC_FILES_PATH,
EXPORT_DETAIL,
EXPORT_MARKER,
IMAGES_MANIFEST,
PAGES_MANIFEST,
PHASE_PRODUCTION_BUILD,
PRERENDER_MANIFEST,
Expand Down Expand Up @@ -1109,6 +1110,15 @@ export default async function build(
)
}

await promises.writeFile(
path.join(distDir, IMAGES_MANIFEST),
JSON.stringify({
version: 1,
images: config.images,
}),
'utf8'
)

await promises.writeFile(
path.join(distDir, EXPORT_MARKER),
JSON.stringify({
Expand Down
1 change: 1 addition & 0 deletions packages/next/next-server/lib/constants.ts
Expand Up @@ -8,6 +8,7 @@ export const EXPORT_MARKER = 'export-marker.json'
export const EXPORT_DETAIL = 'export-detail.json'
export const PRERENDER_MANIFEST = 'prerender-manifest.json'
export const ROUTES_MANIFEST = 'routes-manifest.json'
export const IMAGES_MANIFEST = 'images-manifest.json'
export const DEV_CLIENT_PAGES_MANIFEST = '_devPagesManifest.json'
export const REACT_LOADABLE_MANIFEST = 'react-loadable-manifest.json'
export const FONT_MANIFEST = 'font-manifest.json'
Expand Down
49 changes: 47 additions & 2 deletions packages/next/next-server/server/config.ts
Expand Up @@ -23,7 +23,11 @@ const defaultConfig: { [key: string]: any } = {
target: 'server',
poweredByHeader: true,
compress: true,
images: { hosts: { default: { path: 'defaultconfig' } } },
images: {
sizes: [320, 420, 768, 1024, 1200],
domains: [],
hosts: { default: { path: 'defaultconfig' } },
},
devIndicators: {
buildActivity: true,
autoPrerender: true,
Expand Down Expand Up @@ -208,6 +212,47 @@ function assignDefaults(userConfig: { [key: string]: any }) {
}
}

if (result?.images) {
const { images } = result
if (typeof images !== 'object') {
throw new Error(
`Specified images should be an object received ${typeof images}`
)
}
if (images.domains) {
if (!Array.isArray(images.domains)) {
throw new Error(
`Specified images.domains should be an Array received ${typeof images.domains}`
)
}
const invalid = images.domains.filter(
(d: unknown) => typeof d !== 'string'
)
if (invalid.length > 0) {
throw new Error(
`Specified images.domains should be an Array of strings received invalid values (${invalid.join(
', '
)})`
)
}
}
if (images.sizes) {
if (!Array.isArray(images.sizes)) {
throw new Error(
`Specified images.sizes should be an Array received ${typeof images.sizes}`
)
}
const invalid = images.sizes.filter((d: unknown) => typeof d !== 'number')
if (invalid.length > 0) {
throw new Error(
`Specified images.sizes should be an Array of numbers received invalid values (${invalid.join(
', '
)})`
)
}
}
}

if (result.experimental?.i18n) {
const { i18n } = result.experimental
const i18nType = typeof i18n
Expand All @@ -218,7 +263,7 @@ function assignDefaults(userConfig: { [key: string]: any }) {

if (!Array.isArray(i18n.locales)) {
throw new Error(
`Specified i18n.locales should be an Array received ${typeof i18n.lcoales}`
`Specified i18n.locales should be an Array received ${typeof i18n.locales}`
)
}

Expand Down
244 changes: 244 additions & 0 deletions packages/next/next-server/server/image-optimizer.ts
@@ -0,0 +1,244 @@
import { UrlWithParsedQuery } from 'url'
import { IncomingMessage, ServerResponse } from 'http'
import { join } from 'path'
import { mediaType } from '@hapi/accept'
import { createReadStream, promises } from 'fs'
import { createHash } from 'crypto'
import Server from './next-server'
import { getContentType, getExtension } from './serve-static'
import { fileExists } from '../../lib/file-exists'

let sharp: typeof import('sharp')
//const AVIF = 'image/avif'
const WEBP = 'image/webp'
const PNG = 'image/png'
const JPEG = 'image/jpeg'
const MIME_TYPES = [/* AVIF, */ WEBP, PNG, JPEG]
const CACHE_VERSION = 1

export async function imageOptimizer(
server: Server,
req: IncomingMessage,
res: ServerResponse,
parsedUrl: UrlWithParsedQuery
) {
const { nextConfig, distDir } = server
const { sizes = [], domains = [] } = nextConfig?.images || {}
const { headers } = req
const { url, w, q } = parsedUrl.query
const proto = headers['x-forwarded-proto'] || 'http'
const host = headers['x-forwarded-host'] || headers.host
const mimeType = mediaType(headers.accept, MIME_TYPES) || ''

if (!url) {
res.statusCode = 400
res.end('"url" parameter is required')
return { finished: true }
} else if (Array.isArray(url)) {
res.statusCode = 400
res.end('"url" parameter cannot be an array')
return { finished: true }
}

let absoluteUrl: URL
try {
absoluteUrl = new URL(url)
} catch (_error) {
// url was not absolute so assuming relative url
try {
absoluteUrl = new URL(url, `${proto}://${host}`)
} catch (__error) {
res.statusCode = 400
res.end('"url" parameter is invalid')
return { finished: true }
}
}

if (!['http:', 'https:'].includes(absoluteUrl.protocol)) {
res.statusCode = 400
res.end('"url" parameter is invalid')
return { finished: true }
}

if (!server.renderOpts.dev && !domains.includes(absoluteUrl.hostname)) {
res.statusCode = 400
res.end('"url" parameter is not allowed')
return { finished: true }
}

if (!w) {
res.statusCode = 400
res.end('"w" parameter (width) is required')
return { finished: true }
} else if (Array.isArray(w)) {
res.statusCode = 400
res.end('"w" parameter (width) cannot be an array')
return { finished: true }
}

if (!q) {
res.statusCode = 400
res.end('"q" parameter (quality) is required')
return { finished: true }
} else if (Array.isArray(q)) {
res.statusCode = 400
res.end('"q" parameter (quality) cannot be an array')
return { finished: true }
}

const width = parseInt(w, 10)

if (!width || isNaN(width)) {
res.statusCode = 400
res.end('"w" parameter (width) must be a number greater than 0')
return { finished: true }
}

if (!sizes.includes(width)) {
res.statusCode = 400
res.end(`"w" parameter (width) of ${width} is not allowed`)
return { finished: true }
}

const quality = parseInt(q)

if (isNaN(quality) || quality < 1 || quality > 100) {
res.statusCode = 400
res.end('"q" parameter (quality) must be a number between 1 and 100')
return { finished: true }
}

const { href } = absoluteUrl
const hash = getHash([CACHE_VERSION, href, width, quality, mimeType])
const imagesDir = join(distDir, 'cache', 'images')
const hashDir = join(imagesDir, hash)
const now = Date.now()

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 contentType = getContentType(extension)
if (now < expireAt) {
if (contentType) {
res.setHeader('Content-Type', contentType)
}
createReadStream(join(hashDir, file)).pipe(res)
return { finished: true }
} else {
await promises.unlink(join(hashDir, file))
}
}
}

const upstreamRes = await fetch(href)

if (!upstreamRes.ok) {
res.statusCode = upstreamRes.status
res.end('"url" parameter is valid but upstream response is invalid')
return { finished: true }
}

const upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer())
const upstreamType = upstreamRes.headers.get('Content-Type')
const maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control'))
const expireAt = maxAge * 1000 + now
let contentType: string

if (mimeType) {
contentType = mimeType
} else if (upstreamType?.startsWith('image/') && getExtension(upstreamType)) {
contentType = upstreamType
} else {
contentType = JPEG
}

if (!sharp) {
try {
// eslint-disable-next-line import/no-extraneous-dependencies
sharp = require('sharp')
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
error.message +=
"\nTo use Next.js' built-in Image Optimization, you first need to install `sharp`."
error.message +=
'\nRun `npm i sharp` or `yarn add sharp` inside your workspace.'
error.message += '\n\nLearn more: https://err.sh/next.js/install-sharp'
}
throw error
}
}

const transformer = sharp(upstreamBuffer).resize(width)

//if (contentType === AVIF) {
// Soon https://github.com/lovell/sharp/issues/2289
//}
if (contentType === WEBP) {
transformer.webp({ quality })
} else if (contentType === PNG) {
transformer.png({ quality })
} else if (contentType === JPEG) {
transformer.jpeg({ quality })
}

try {
const optimizedBuffer = await transformer.toBuffer()
await promises.mkdir(hashDir, { recursive: true })
const extension = getExtension(contentType)
const filename = join(hashDir, `${expireAt}.${extension}`)
await promises.writeFile(filename, optimizedBuffer)
res.setHeader('Content-Type', contentType)
res.end(optimizedBuffer)
} catch (error) {
server.logError(error)
if (upstreamType) {
res.setHeader('Content-Type', upstreamType)
}
res.end(upstreamBuffer)
}

return { finished: true }
}

function getHash(items: (string | number | undefined)[]) {
const hash = createHash('sha256')
for (let item of items) {
hash.update(String(item))
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-')
}

function parseCacheControl(str: string | null): Map<string, string> {
const map = new Map<string, string>()
if (!str) {
return map
}
for (let directive of str.split(',')) {
let [key, value] = directive.trim().split('=')
key = key.toLowerCase()
if (value) {
value = value.toLowerCase()
}
map.set(key, value)
}
return map
}

export function getMaxAge(str: string | null): number {
const minimum = 60
const map = parseCacheControl(str)
if (map) {
let age = map.get('s-maxage') || map.get('max-age') || ''
if (age.startsWith('"') && age.endsWith('"')) {
age = age.slice(1, -1)
}
const n = parseInt(age, 10)
if (!isNaN(n)) {
return Math.max(n, minimum)
}
}
return minimum
}

0 comments on commit d3741d5

Please sign in to comment.