diff --git a/errors/install-sharp.md b/errors/install-sharp.md new file mode 100644 index 000000000000000..addd72914f732b8 --- /dev/null +++ b/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 +``` diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 05f371f6d910309..046ad6a69aa74f4 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -29,6 +29,7 @@ import { CLIENT_STATIC_FILES_PATH, EXPORT_DETAIL, EXPORT_MARKER, + IMAGES_MANIFEST, PAGES_MANIFEST, PHASE_PRODUCTION_BUILD, PRERENDER_MANIFEST, @@ -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({ diff --git a/packages/next/next-server/lib/constants.ts b/packages/next/next-server/lib/constants.ts index 8257e98eac59680..09e9e20aeb16c5e 100644 --- a/packages/next/next-server/lib/constants.ts +++ b/packages/next/next-server/lib/constants.ts @@ -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' diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index a49a886f2fcd117..891632794e63333 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -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, @@ -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 @@ -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}` ) } diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts new file mode 100644 index 000000000000000..7a5c64093b55ab6 --- /dev/null +++ b/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 { + const map = new Map() + 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 +} diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 454332e8cd6e29b..fe1e5cfcb58f26a 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -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' import { detectDomainLocale } from '../lib/i18n/detect-domain-locale' import cookie from 'next/dist/compiled/cookie' @@ -271,7 +272,7 @@ export default class Server { return PHASE_PRODUCTION_SERVER } - private logError(err: Error): void { + public logError(err: Error): void { if (this.onErrorMiddleware) { this.onErrorMiddleware({ err }) } @@ -472,6 +473,7 @@ export default class Server { useFileSystemPublicRoutes: boolean dynamicRoutes: DynamicRoutes | undefined } { + const server: Server = this const publicRoutes = fs.existsSync(this.publicDir) ? this.generatePublicRoutes() : [] @@ -589,6 +591,13 @@ export default class Server { } }, }, + { + match: route('/_next/image'), + type: 'route', + name: '_next/image catchall', + fn: (req, res, _params, parsedUrl) => + imageOptimizer(server, req, res, parsedUrl), + }, { match: route('/_next/:path*'), type: 'route', diff --git a/packages/next/next-server/server/serve-static.ts b/packages/next/next-server/server/serve-static.ts index fccbe65d3254610..52826b92e5f7eb2 100644 --- a/packages/next/next-server/server/serve-static.ts +++ b/packages/next/next-server/server/serve-static.ts @@ -19,3 +19,23 @@ export function serveStatic( .on('finish', resolve) }) } + +export function getContentType(extWithoutDot: string): string | null { + const { mime } = send + if ('getType' in mime) { + // 2.0 + return mime.getType(extWithoutDot) + } + // 1.0 + return (mime as any).lookup(extWithoutDot) +} + +export function getExtension(contentType: string): string | null { + const { mime } = send + if ('getExtension' in mime) { + // 2.0 + return mime.getExtension(contentType) + } + // 1.0 + return (mime as any).extension(contentType) +} diff --git a/packages/next/package.json b/packages/next/package.json index 810e6c593c29a8d..ab4ee1a71c51383 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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", @@ -202,6 +203,7 @@ "resolve": "1.11.0", "semver": "7.3.2", "send": "0.17.1", + "sharp": "0.26.2", "source-map": "0.6.1", "string-hash": "1.1.3", "strip-ansi": "6.0.0", diff --git a/test/integration/image-optimizer/next.config.js b/test/integration/image-optimizer/next.config.js new file mode 100644 index 000000000000000..6b05babba937329 --- /dev/null +++ b/test/integration/image-optimizer/next.config.js @@ -0,0 +1,2 @@ +// prettier-ignore +module.exports = { /* replaceme */ } diff --git a/test/integration/image-optimizer/pages/index.js b/test/integration/image-optimizer/pages/index.js new file mode 100644 index 000000000000000..ff208d477174466 --- /dev/null +++ b/test/integration/image-optimizer/pages/index.js @@ -0,0 +1,5 @@ +function Home() { + return

Image Optimizer Home

+} + +export default Home diff --git a/test/integration/image-optimizer/public/test.bmp b/test/integration/image-optimizer/public/test.bmp new file mode 100644 index 000000000000000..f33feda8616b735 Binary files /dev/null and b/test/integration/image-optimizer/public/test.bmp differ diff --git a/test/integration/image-optimizer/public/test.gif b/test/integration/image-optimizer/public/test.gif new file mode 100644 index 000000000000000..6bbbd315e9fe876 Binary files /dev/null and b/test/integration/image-optimizer/public/test.gif differ diff --git a/test/integration/image-optimizer/public/test.jpg b/test/integration/image-optimizer/public/test.jpg new file mode 100644 index 000000000000000..d536c882412ed3d Binary files /dev/null and b/test/integration/image-optimizer/public/test.jpg differ diff --git a/test/integration/image-optimizer/public/test.png b/test/integration/image-optimizer/public/test.png new file mode 100644 index 000000000000000..e14fafc5cf3bc63 Binary files /dev/null and b/test/integration/image-optimizer/public/test.png differ diff --git a/test/integration/image-optimizer/public/test.svg b/test/integration/image-optimizer/public/test.svg new file mode 100644 index 000000000000000..025d874f92e6a3c --- /dev/null +++ b/test/integration/image-optimizer/public/test.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/test/integration/image-optimizer/public/test.tiff b/test/integration/image-optimizer/public/test.tiff new file mode 100644 index 000000000000000..c2cc3e203bb3fdb Binary files /dev/null and b/test/integration/image-optimizer/public/test.tiff differ diff --git a/test/integration/image-optimizer/test/get-max-age.test.js b/test/integration/image-optimizer/test/get-max-age.test.js new file mode 100644 index 000000000000000..b790a456228bd0a --- /dev/null +++ b/test/integration/image-optimizer/test/get-max-age.test.js @@ -0,0 +1,44 @@ +/* eslint-env jest */ +import { getMaxAge } from '../../../../packages/next/dist/next-server/server/image-optimizer.js' + +describe('getMaxAge', () => { + it('should return default when no cache-control provided', () => { + expect(getMaxAge()).toBe(60) + }) + it('should return default when cache-control is null', () => { + expect(getMaxAge(null)).toBe(60) + }) + it('should return default when cache-control is empty string', () => { + expect(getMaxAge('')).toBe(60) + }) + it('should return default when cache-control max-age is less than default', () => { + expect(getMaxAge('max-age=30')).toBe(60) + }) + it('should return default when cache-control max-age is not a number', () => { + expect(getMaxAge('max-age=foo')).toBe(60) + }) + it('should return default when cache-control is no-cache', () => { + expect(getMaxAge('no-cache')).toBe(60) + }) + it('should return cache-control max-age lowercase', () => { + expect(getMaxAge('max-age=9999')).toBe(9999) + }) + it('should return cache-control MAX-AGE uppercase', () => { + expect(getMaxAge('MAX-AGE=9999')).toBe(9999) + }) + it('should return cache-control s-maxage lowercase', () => { + expect(getMaxAge('s-maxage=9999')).toBe(9999) + }) + it('should return cache-control S-MAXAGE', () => { + expect(getMaxAge('S-MAXAGE=9999')).toBe(9999) + }) + it('should return cache-control s-maxage with spaces', () => { + expect(getMaxAge('public, max-age=5555, s-maxage=9999')).toBe(9999) + }) + it('should return cache-control s-maxage without spaces', () => { + expect(getMaxAge('public,s-maxage=9999,max-age=5555')).toBe(9999) + }) + it('should return cache-control for a quoted value', () => { + expect(getMaxAge('public, s-maxage="9999", max-age="5555"')).toBe(9999) + }) +}) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js new file mode 100644 index 000000000000000..73233695b4e1cc2 --- /dev/null +++ b/test/integration/image-optimizer/test/index.test.js @@ -0,0 +1,348 @@ +/* eslint-env jest */ +import fs from 'fs-extra' +import { join } from 'path' +import { + killApp, + findPort, + launchApp, + fetchViaHTTP, + nextBuild, + nextStart, + File, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '../') +const imagesDir = join(appDir, '.next', 'cache', 'images') +const nextConfig = new File(join(appDir, 'next.config.js')) +let appPort +let app + +async function fsToJson(dir, output = {}) { + const files = await fs.readdir(dir) + for (let file of files) { + const fsPath = join(dir, file) + const stat = await fs.stat(fsPath) + if (stat.isDirectory()) { + output[file] = {} + await fsToJson(fsPath, output[file]) + } else { + output[file] = 'file' + } + } + return output +} + +function runTests({ w, isDev }) { + it('should return home page', async () => { + const res = await fetchViaHTTP(appPort, '/', null, {}) + expect(await res.text()).toMatch(/Image Optimizer Home/m) + }) + + it('should fail when url is missing', async () => { + const query = { w, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is required`) + }) + + it('should fail when w is missing', async () => { + const query = { url: '/test.png', q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"w" parameter (width) is required`) + }) + + it('should fail when q is missing', async () => { + const query = { url: '/test.png', w } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"q" parameter (quality) is required`) + }) + + it('should fail when q is greater than 100', async () => { + const query = { url: '/test.png', w, q: 101 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + it('should fail when q is less than 1', async () => { + const query = { url: '/test.png', w, q: 0 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + it('should fail when w is 0 or less', async () => { + const query = { url: '/test.png', w: 0, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) must be a number greater than 0` + ) + }) + + it('should fail when w is not a number', async () => { + const query = { url: '/test.png', w: 'foo', q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) must be a number greater than 0` + ) + }) + + it('should fail when q is not a number', async () => { + const query = { url: '/test.png', w, q: 'foo' } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"q" parameter (quality) must be a number between 1 and 100` + ) + }) + + if (!isDev) { + it('should fail when domain is not defined in next.config.js', async () => { + const url = `http://vercel.com/button` + const query = { url, w, q: 100 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is not allowed`) + }) + } + + it('should fail when width is not in next.config.js', async () => { + const query = { url: '/test.png', w: 1000, q: 100 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe( + `"w" parameter (width) of 1000 is not allowed` + ) + }) + + it('should resize relative url and webp accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should resize relative url and jpeg accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/jpeg' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + }) + + it('should resize relative url and png accept header', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/png' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/png') + }) + + it('should resize relative url with invalid accept header as png', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/png') + }) + + it('should resize relative url with invalid accept header as gif', async () => { + const query = { url: '/test.gif', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/gif') + }) + + it('should resize relative url with invalid accept header as svg', async () => { + const query = { url: '/test.svg', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/svg+xml') + }) + + it('should resize relative url with invalid accept header as tiff', async () => { + const query = { url: '/test.tiff', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/tiff') + }) + + it('should resize relative url and wildcard accept header as webp', async () => { + const query = { url: '/test.png', w, q: 80 } + const opts = { headers: { accept: 'image/*' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should resize absolute url from localhost', async () => { + const url = `http://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + }) + + it('should fail when url has file protocol', async () => { + const url = `file://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is invalid`) + }) + + it('should fail when url has ftp protocol', async () => { + const url = `ftp://localhost:${appPort}/test.png` + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(400) + expect(await res.text()).toBe(`"url" parameter is invalid`) + }) + + it('should fail when url fails to load an image', async () => { + const url = `http://localhost:${appPort}/not-an-image` + const query = { w, url, q: 100 } + const res = await fetchViaHTTP(appPort, '/_next/image', query, {}) + expect(res.status).toBe(404) + expect(await res.text()).toBe( + `"url" parameter is valid but upstream response is invalid` + ) + }) + + it('should use cached image file when parameters are the same', 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('Content-Type')).toBe('image/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('Content-Type')).toBe('image/webp') + const json2 = await fsToJson(imagesDir) + expect(json2).toStrictEqual(json1) + }) + + it('should proxy-pass unsupported image types and should not cache file', async () => { + const json1 = await fsToJson(imagesDir) + expect(json1).toBeTruthy() + + const query = { url: '/test.bmp', w, q: 80 } + const opts = { headers: { accept: 'image/invalid' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/bmp') + + const json2 = await fsToJson(imagesDir) + expect(json2).toStrictEqual(json1) + }) +} + +describe('Image Optimizer', () => { + describe('dev support w/o next.config.js', () => { + const size = 768 // defaults defined in server/config.ts + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true }) + }) + + describe('dev support with next.config.js', () => { + const size = 64 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + sizes: [size], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true }) + }) + + describe('Server support with next.config.js', () => { + const size = 128 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + sizes: [128], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false }) + }) + + describe('Serverless support with next.config.js', () => { + const size = 256 + beforeAll(async () => { + const json = JSON.stringify({ + target: 'experimental-serverless-trace', + images: { + sizes: [size], + domains: ['localhost', 'example.com'], + }, + }) + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 86363b87b344fde..e9616c3cb7a1adb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3392,6 +3392,13 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/sharp@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.26.0.tgz#2fa8419dbdaca8dd38f73888b27b207f188a8669" + integrity sha512-oJrR8eiwpL7qykn2IeFRduXM4za7z+7yOUEbKVtuDQ/F6htDLHYO6IbzhaJQHV5n6O3adIh4tJvtgPyLyyydqg== + dependencies: + "@types/node" "*" + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -5358,6 +5365,14 @@ color-string@^1.5.2: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" + integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color@^0.11.0: version "0.11.4" resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" @@ -5373,6 +5388,14 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.2" +color@^3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" + integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.4" + colormin@^1.0.5: version "1.1.2" resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" @@ -6360,6 +6383,13 @@ decompress-response@^5.0.0: dependencies: mimic-response "^2.0.0" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -11175,6 +11205,11 @@ mimic-response@^2.0.0, mimic-response@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -11213,7 +11248,7 @@ minimist-options@^4.0.2: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" @@ -11299,6 +11334,11 @@ mk-dirs@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mk-dirs/-/mk-dirs-1.0.0.tgz#44ee67f82341c6762718e88e85e577882e1f67fd" +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" @@ -11456,6 +11496,11 @@ node-addon-api@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492" +node-addon-api@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" + integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== + node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -13544,6 +13589,27 @@ prebuild-install@^5.3.2: tunnel-agent "^0.6.0" which-pm-runs "^1.0.0" +prebuild-install@^5.3.5: + version "5.3.5" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-5.3.5.tgz#e7e71e425298785ea9d22d4f958dbaccf8bb0e1b" + integrity sha512-YmMO7dph9CYKi5IR/BzjOJlRzpxGGVo1EsLSUZ0mt/Mq0HWZIHOKHHcHdT69yG54C9m6i45GpItwRHpk0Py7Uw== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp "^0.5.1" + napi-build-utils "^1.0.1" + node-abi "^2.7.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + which-pm-runs "^1.0.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -15111,6 +15177,21 @@ shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" +sharp@0.26.2: + version "0.26.2" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.26.2.tgz#3d5777d246ae32890afe82a783c1cbb98456a88c" + integrity sha512-bGBPCxRAvdK9bX5HokqEYma4j/Q5+w8Nrmb2/sfgQCLEUx/HblcpmOfp59obL3+knIKnOhyKmDb4tEOhvFlp6Q== + dependencies: + color "^3.1.2" + detect-libc "^1.0.3" + node-addon-api "^3.0.2" + npmlog "^4.1.2" + prebuild-install "^5.3.5" + semver "^7.3.2" + simple-get "^4.0.0" + tar-fs "^2.1.0" + tunnel-agent "^0.6.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -15155,6 +15236,15 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" +simple-get@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675" + integrity sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -15884,6 +15974,16 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.0.0" +tar-fs@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5" + integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + tar-stream@2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.3.tgz#1e2022559221b7866161660f118255e20fa79e41"