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 support for Image Optimizer #17749

Merged
merged 51 commits into from Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0d61e66
[image-optimizer] Initial commit
styfle Oct 8, 2020
19c4680
Fix several bugs
styfle Oct 9, 2020
7920ad5
Fix relative urls
styfle Oct 9, 2020
b5ad58d
Add initial test fixture
styfle Oct 9, 2020
689f7c1
Add tests
styfle Oct 9, 2020
6d2c58d
Add missing header
styfle Oct 9, 2020
3257808
Update per review comments
styfle Oct 10, 2020
b5e258d
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 10, 2020
db041fc
Remove test repo
styfle Oct 10, 2020
bbdfd60
Run lint
styfle Oct 10, 2020
b5f1dfc
Regenerate yarn.lock
styfle Oct 12, 2020
7e33323
Remove sharp from pnp test
styfle Oct 12, 2020
23ea3bf
Fix sharp install with env hack
styfle Oct 12, 2020
60b7221
Use env property
styfle Oct 12, 2020
05c3cc3
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 12, 2020
4118aa2
Use exact version
styfle Oct 12, 2020
0eeba5c
Prevent accidental octal or hexidecimal
styfle Oct 12, 2020
cd4e387
Add cache expiration
styfle Oct 12, 2020
44bc0d9
Regenerate yarn.lock
styfle Oct 12, 2020
7e5233d
Add unit tests for getMaxAge()
styfle Oct 12, 2020
170ce0f
Fix typo
styfle Oct 12, 2020
88f82ac
Fix cache expiration and add a test
styfle Oct 12, 2020
38273f6
Add more tests for max-age header
styfle Oct 12, 2020
14b0538
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 12, 2020
e1d6586
Fallback to png and jpeg
styfle Oct 13, 2020
d24cf43
Add file extension for unsupported image types
styfle Oct 13, 2020
5093267
Add tests for other file types
styfle Oct 13, 2020
081cbb5
Clean up tests
styfle Oct 13, 2020
2dd646d
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 13, 2020
254df71
should proxy-pass unsupported images
styfle Oct 13, 2020
a53ce3e
Apply suggestions from JJ
styfle Oct 14, 2020
866142c
Add parsedUrl parameter
styfle Oct 14, 2020
6d591ae
Update cache fs test
styfle Oct 14, 2020
d7b0ccc
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 14, 2020
4233849
Add defaults per timneutkens
styfle Oct 14, 2020
55dc28d
Perform local reads for relative urls
styfle Oct 14, 2020
532a164
Run prettier
styfle Oct 14, 2020
b20cfd2
Change fs back to fetch()
styfle Oct 14, 2020
fa4c1e0
Fix tests
styfle Oct 14, 2020
5ecacb5
Apply suggestions from JJ
styfle Oct 15, 2020
6f63854
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 15, 2020
e64f680
Friendly error when sharp is missing
styfle Oct 15, 2020
2776d90
Lint
styfle Oct 15, 2020
e377ecf
Update sharp to 0.26.2
styfle Oct 15, 2020
301512e
Remove "experimental" and write images-manifest.json
styfle Oct 15, 2020
f6670e5
Remove one more experimental
styfle Oct 15, 2020
59a26c0
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 15, 2020
b2e11f0
Add docs for installing sharp
styfle Oct 15, 2020
6e0c905
Throw error when sharp was not found
styfle Oct 15, 2020
c53aec6
Merge branch 'canary' into rfc17141-next-image-api
styfle Oct 15, 2020
17cddcb
Add test for invalid image
styfle Oct 15, 2020
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
162 changes: 162 additions & 0 deletions packages/next/next-server/server/image-optimizer.ts
@@ -0,0 +1,162 @@
import { parse } from 'url'
import { IncomingMessage, ServerResponse } from 'http'
import { join } from 'path'
import accept from '@hapi/accept'
import { createReadStream, createWriteStream, promises } from 'fs'
import { createHash } from 'crypto'
import Server from './next-server'
import { fileExists } from '../../lib/file-exists'

let sharp: typeof import('sharp')
const AVIF = 'image/avif'
const WEBP = 'image/webp'
const MEDIA_TYPES = [/* AVIF, */ WEBP]

export async function imageOptimizer(
server: Server,
req: IncomingMessage,
res: ServerResponse
) {
const { nextConfig, distDir } = server
const { sizes = [], domains = [] } = nextConfig?.experimental?.images || {}
const { url: reqUrl = '/', headers } = req
const { query } = parse(reqUrl, true)
styfle marked this conversation as resolved.
Show resolved Hide resolved
const { url, w, q } = query
const proto = headers['x-forwarded-proto'] || 'http'
const host = headers['x-forwarded-host'] || headers.host
const mediaType = accept.mediaType(req.headers.accept, MEDIA_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)

if (
Array.isArray(domains) &&
domains.length > 0 &&
!domains.includes(absoluteUrl.hostname)
styfle marked this conversation as resolved.
Show resolved Hide resolved
) {
res.statusCode = 400
res.end('"url" parameter is not allowed')
return { finished: true }
}
} 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 (!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 }
}
ijjk marked this conversation as resolved.
Show resolved Hide resolved

const width = parseInt(w)
styfle marked this conversation as resolved.
Show resolved Hide resolved

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

if (Array.isArray(sizes) && sizes.length > 0 && !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) {
nkzawa marked this conversation as resolved.
Show resolved Hide resolved
res.statusCode = 400
res.end('"q" parameter (quality) must be a number between 1 and 100')
return { finished: true }
}

const { href } = absoluteUrl
const fileName = getFileName([href, width, quality, mediaType])
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
const imageDir = join(distDir, 'cache', 'images')
const cacheFile = join(imageDir, fileName)

if (await fileExists(cacheFile)) {
res.setHeader('Content-Type', mediaType)
createReadStream(cacheFile).pipe(res)
return { finished: true }
ijjk marked this conversation as resolved.
Show resolved Hide resolved
}

if (!sharp) {
// Lazy load sharp per RFC 17141
styfle marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line import/no-extraneous-dependencies
sharp = require('sharp')
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
}
const transformer = sharp().resize(width)

if (mediaType === AVIF) {
styfle marked this conversation as resolved.
Show resolved Hide resolved
// Soon https://github.com/lovell/sharp/issues/2289
}
if (mediaType === WEBP) {
transformer.webp({ quality })
}

const fetchResponse = await fetch(href)

if (!fetchResponse.ok) {
throw new Error(`Unexpected status ${fetchResponse.status} from ${href}`)
}
if (!fetchResponse.body) {
throw new Error(`No body from ${href}`)
}

await promises.mkdir(imageDir, { recursive: true })

// We know this code only runs server-side so use Node Streams
const body = (fetchResponse.body as any) as NodeJS.ReadableStream
const imageTransform = body.pipe(transformer)
imageTransform.pipe(createWriteStream(cacheFile))
styfle marked this conversation as resolved.
Show resolved Hide resolved
res.setHeader('Content-Type', mediaType)
imageTransform.pipe(res)
return { finished: true }
}

function getFileName(items: (string | number | undefined)[]) {
const hash = createHash('sha256')
for (let item of items) {
if (typeof item === 'string') {
hash.update(item)
} else {
hash.update(String(item))
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
const digest = hash.digest('base64').replace(/\//g, '-')
return digest
}
8 changes: 8 additions & 0 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -79,6 +79,7 @@ import accept from '@hapi/accept'
import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path'
import { detectLocaleCookie } from '../lib/i18n/detect-locale-cookie'
import * as Log from '../../build/output/log'
import { imageOptimizer } from './image-optimizer'

const getCustomRouteMatcher = pathMatch(true)

Expand Down Expand Up @@ -413,6 +414,7 @@ export default class Server {
useFileSystemPublicRoutes: boolean
dynamicRoutes: DynamicRoutes | undefined
} {
const server: Server = this
const publicRoutes = fs.existsSync(this.publicDir)
? this.generatePublicRoutes()
: []
Expand Down Expand Up @@ -528,6 +530,12 @@ export default class Server {
}
},
},
{
match: route('/_next/image'),
type: 'route',
name: '_next/image catchall',
fn: (req, res) => imageOptimizer(server, req, res),
styfle marked this conversation as resolved.
Show resolved Hide resolved
},
{
match: route('/_next/:path*'),
type: 'route',
Expand Down
2 changes: 2 additions & 0 deletions packages/next/package.json
Expand Up @@ -156,6 +156,7 @@
"@types/resolve": "0.0.8",
"@types/semver": "7.3.1",
"@types/send": "0.14.4",
"@types/sharp": "0.26.0",
"@types/styled-jsx": "2.2.8",
"@types/text-table": "0.2.1",
"@types/webpack-sources": "0.1.5",
Expand Down Expand Up @@ -202,6 +203,7 @@
"resolve": "1.11.0",
"semver": "7.3.2",
"send": "0.17.1",
"sharp": "0.26.1",
"source-map": "0.6.1",
"string-hash": "1.1.3",
"strip-ansi": "6.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/styfle.dev
Submodule styfle.dev added at bb9018
8 changes: 8 additions & 0 deletions test/integration/image-optimizer/next.config.js
@@ -0,0 +1,8 @@
module.exports = {
experimental: {
styfle marked this conversation as resolved.
Show resolved Hide resolved
images: {
sizes: [64, 128],
domains: ['localhost', 'example.com'],
},
},
}
5 changes: 5 additions & 0 deletions test/integration/image-optimizer/pages/index.js
@@ -0,0 +1,5 @@
function Home() {
return <h1>Image Optimizer Home</h1>
}

export default Home
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.