From 1b733423d6d812a8bc9bd47ef87769c3599e7127 Mon Sep 17 00:00:00 2001 From: Alex Castle Date: Thu, 22 Jul 2021 16:11:17 -0700 Subject: [PATCH] Use Sharp if available for Image Optimization (#27346) * Use sharp for image transformations when available * Refactor resizeImage and add sharp warning * only show sharp warning once per instance * Modify sharp error message * Add documentation for optional sharp dependency * Update docs/basic-features/image-optimization.md Co-authored-by: Steven * Import types for sharp * Update lockfile * Add testing against sharp * use fs-extra for node 12 * Rename test sharp path variable * Apply suggestions from code review Co-authored-by: Steven * update squoosh specific test * Apply suggestions from code review Co-authored-by: Steven * update tests * Apply suggestions from code review Co-authored-by: Steven Co-authored-by: Steven Co-authored-by: JJ Kasper --- docs/basic-features/image-optimization.md | 2 + errors/manifest.json | 4 + errors/sharp-missing-in-production.md | 13 + package.json | 1 + .../webpack/loaders/next-image-loader.js | 16 +- packages/next/server/image-optimizer.ts | 168 +++++++--- .../image-optimizer/app/.gitignore | 3 + .../image-optimizer/{ => app}/next.config.js | 0 .../image-optimizer/{ => app}/pages/index.js | 0 .../{ => app}/public/animated.gif | Bin .../{ => app}/public/animated.png | Bin .../{ => app}/public/animated.webp | Bin .../{ => app}/public/grayscale.png | Bin .../image-optimizer/{ => app}/public/test.bmp | Bin .../image-optimizer/{ => app}/public/test.gif | Bin .../image-optimizer/{ => app}/public/test.ico | Bin .../image-optimizer/{ => app}/public/test.jpg | Bin .../image-optimizer/{ => app}/public/test.png | Bin .../image-optimizer/{ => app}/public/test.svg | 0 .../{ => app}/public/test.tiff | Bin .../image-optimizer/{ => app}/public/text.txt | 0 ...st.js => detect-content-type.unit.test.js} | 10 +- ...x-age.test.js => get-max-age.unit.test.js} | 0 .../image-optimizer/test/index.test.js | 309 +++++++++++------- yarn.lock | 7 + 25 files changed, 360 insertions(+), 173 deletions(-) create mode 100644 errors/sharp-missing-in-production.md create mode 100644 test/integration/image-optimizer/app/.gitignore rename test/integration/image-optimizer/{ => app}/next.config.js (100%) rename test/integration/image-optimizer/{ => app}/pages/index.js (100%) rename test/integration/image-optimizer/{ => app}/public/animated.gif (100%) rename test/integration/image-optimizer/{ => app}/public/animated.png (100%) rename test/integration/image-optimizer/{ => app}/public/animated.webp (100%) rename test/integration/image-optimizer/{ => app}/public/grayscale.png (100%) rename test/integration/image-optimizer/{ => app}/public/test.bmp (100%) rename test/integration/image-optimizer/{ => app}/public/test.gif (100%) rename test/integration/image-optimizer/{ => app}/public/test.ico (100%) rename test/integration/image-optimizer/{ => app}/public/test.jpg (100%) rename test/integration/image-optimizer/{ => app}/public/test.png (100%) rename test/integration/image-optimizer/{ => app}/public/test.svg (100%) rename test/integration/image-optimizer/{ => app}/public/test.tiff (100%) rename test/integration/image-optimizer/{ => app}/public/text.txt (100%) rename test/integration/image-optimizer/test/{detect-content-type.test.js => detect-content-type.unit.test.js} (70%) rename test/integration/image-optimizer/test/{get-max-age.test.js => get-max-age.unit.test.js} (100%) diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index e9da1121f65b6..f7ce5c5d92230 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -128,6 +128,8 @@ If you need a different provider, you can use the [`loader`](/docs/api-reference > The `next/image` component's default loader is not supported when using [`next export`](/docs/advanced-features/static-html-export.md). However, other loader options will work. +> The `next/image` component's default loader uses the ['squoosh'](https://www.npmjs.com/package/@squoosh/lib) library for image resizing and optimization. This library is quick to install and suitable for a dev server environment. For a production environment, it is strongly recommended that you install the optional [`sharp`](https://www.npmjs.com/package/sharp) library by running `yarn add sharp` in your project directory. If sharp is already installed but can't be resolved you can manually pass the path to it via the `NEXT_SHARP_PATH` environment variable e.g. `NEXT_SHARP_PATH=/tmp/node_modules/sharp` + ## Caching The following describes the caching algorithm for the default [loader](#loader). For all other loaders, please refer to your cloud provider's documentation. diff --git a/errors/manifest.json b/errors/manifest.json index 1ba5b512db5f4..7948b55bc5e7d 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -426,6 +426,10 @@ { "title": "page-data-collection-timeout", "path": "/errors/page-data-collection-timeout.md" + }, + { + "titlle": "sharp-missing-in-production", + "path": "/errors/sharp-missing-in-production.md" } ] } diff --git a/errors/sharp-missing-in-production.md b/errors/sharp-missing-in-production.md new file mode 100644 index 0000000000000..01fc602b4b4e3 --- /dev/null +++ b/errors/sharp-missing-in-production.md @@ -0,0 +1,13 @@ +# Sharp Missing In Production + +#### Why This Error Occurred + +The `next/image` component's default loader uses the ['squoosh'](https://www.npmjs.com/package/@squoosh/lib) library for image resizing and optimization. This library is quick to install and suitable for a dev server environment. For a production environment, it is strongly recommended that you install the optional [`sharp`](https://www.npmjs.com/package/sharp). This package was not detected when leveraging the Image Optimization in production mode (`next start`). + +#### Possible Ways to Fix It + +Install `sharp` by running `yarn add sharp` in your project directory. + +### Useful Links + +- [Image Optimization Documentation](https://nextjs.org/docs/basic-features/image-optimization) diff --git a/package.json b/package.json index cd1a90d36a8b1..5919da3ea226e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/fs-extra": "8.1.0", "@types/http-proxy": "1.17.3", "@types/jest": "24.0.13", + "@types/sharp": "0.28.4", "@types/string-hash": "1.1.1", "@typescript-eslint/eslint-plugin": "4.22.0", "@typescript-eslint/parser": "4.22.0", diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.js index 87dd74de63d78..e4aff1b76e32f 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.js @@ -1,6 +1,6 @@ import loaderUtils from 'next/dist/compiled/loader-utils' import sizeOf from 'image-size' -import { processBuffer } from '../../../server/lib/squoosh/main' +import { resizeImage } from '../../../server/image-optimizer' const BLUR_IMG_SIZE = 8 const BLUR_QUALITY = 70 @@ -38,14 +38,18 @@ function nextImageLoader(content) { blurDataURL = url.href.slice(prefix.length) } else { // Shrink the image's largest dimension - const resizeOperationOpts = - imageSize.width >= imageSize.height - ? { type: 'resize', width: BLUR_IMG_SIZE } - : { type: 'resize', height: BLUR_IMG_SIZE } + const dimension = + imageSize.width >= imageSize.height ? 'width' : 'height' const resizeImageSpan = imageLoaderSpan.traceChild('image-resize') const resizedImage = await resizeImageSpan.traceAsyncFn(() => - processBuffer(content, [resizeOperationOpts], extension, BLUR_QUALITY) + resizeImage( + content, + dimension, + BLUR_IMG_SIZE, + extension, + BLUR_QUALITY + ) ) const blurDataURLSpan = imageLoaderSpan.traceChild( 'image-base64-tostring' diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index f815892ea4ba8..5ded690e69cca 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -15,6 +15,7 @@ import { processBuffer, Operation } from './lib/squoosh/main' import Server from './next-server' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' +import chalk from 'chalk' //const AVIF = 'image/avif' const WEBP = 'image/webp' @@ -29,6 +30,21 @@ const VECTOR_TYPES = [SVG] const BLUR_IMG_SIZE = 8 // should match `next-image-loader` const inflightRequests = new Map>() +let sharp: + | (( + input?: string | Buffer, + options?: import('sharp').SharpOptions + ) => import('sharp').Sharp) + | undefined + +try { + sharp = require(process.env.NEXT_SHARP_PATH || 'sharp') +} catch (e) { + // Sharp not present on the server, Squoosh fallback will be used +} + +let shouldShowSharpWarning = process.env.NODE_ENV === 'production' + export async function imageOptimizer( server: Server, req: IncomingMessage, @@ -322,52 +338,87 @@ export async function imageOptimizer( } else { contentType = JPEG } - try { - const orientation = await getOrientation(upstreamBuffer) + let optimizedBuffer: Buffer | undefined + if (sharp) { + // Begin sharp transformation logic + const transformer = sharp(upstreamBuffer) - const operations: Operation[] = [] + transformer.rotate() + + const { width: metaWidth } = await transformer.metadata() + + if (metaWidth && metaWidth > width) { + transformer.resize(width) + } + + if (contentType === WEBP) { + transformer.webp({ quality }) + } else if (contentType === PNG) { + transformer.png({ quality }) + } else if (contentType === JPEG) { + transformer.jpeg({ quality }) + } - if (orientation === Orientation.RIGHT_TOP) { - operations.push({ type: 'rotate', numRotations: 1 }) - } else if (orientation === Orientation.BOTTOM_RIGHT) { - operations.push({ type: 'rotate', numRotations: 2 }) - } else if (orientation === Orientation.LEFT_BOTTOM) { - operations.push({ type: 'rotate', numRotations: 3 }) + optimizedBuffer = await transformer.toBuffer() + // End sharp transformation logic } else { - // TODO: support more orientations - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // const _: never = orientation - } + // Show sharp warning in production once + if (shouldShowSharpWarning) { + console.warn( + chalk.yellow.bold('Warning: ') + + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically for image optimization.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production' + ) + shouldShowSharpWarning = false + } - operations.push({ type: 'resize', width }) + // Begin Squoosh transformation logic + const orientation = await getOrientation(upstreamBuffer) - let optimizedBuffer: Buffer | undefined - //if (contentType === AVIF) { - //} else - if (contentType === WEBP) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'webp', - quality - ) - } else if (contentType === PNG) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'png', - quality - ) - } else if (contentType === JPEG) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'jpeg', - quality - ) - } + const operations: Operation[] = [] + + if (orientation === Orientation.RIGHT_TOP) { + operations.push({ type: 'rotate', numRotations: 1 }) + } else if (orientation === Orientation.BOTTOM_RIGHT) { + operations.push({ type: 'rotate', numRotations: 2 }) + } else if (orientation === Orientation.LEFT_BOTTOM) { + operations.push({ type: 'rotate', numRotations: 3 }) + } else { + // TODO: support more orientations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // const _: never = orientation + } + + operations.push({ type: 'resize', width }) + + //if (contentType === AVIF) { + //} else + if (contentType === WEBP) { + optimizedBuffer = await processBuffer( + upstreamBuffer, + operations, + 'webp', + quality + ) + } else if (contentType === PNG) { + optimizedBuffer = await processBuffer( + upstreamBuffer, + operations, + 'png', + quality + ) + } else if (contentType === JPEG) { + optimizedBuffer = await processBuffer( + upstreamBuffer, + operations, + 'jpeg', + quality + ) + } + // End Squoosh transformation logic + } if (optimizedBuffer) { await writeToCacheDir( hashDir, @@ -551,3 +602,42 @@ export function getMaxAge(str: string | null): number { } return 0 } + +export async function resizeImage( + content: Buffer, + dimension: 'width' | 'height', + size: number, + extension: 'webp' | 'png' | 'jpeg', + quality: number +): Promise { + if (sharp) { + const transformer = sharp(content) + + if (extension === 'webp') { + transformer.webp({ quality }) + } else if (extension === 'png') { + transformer.png({ quality }) + } else if (extension === 'jpeg') { + transformer.jpeg({ quality }) + } + if (dimension === 'width') { + transformer.resize(size) + } else { + transformer.resize(null, size) + } + const buf = await transformer.toBuffer() + return buf + } else { + const resizeOperationOpts: Operation = + dimension === 'width' + ? { type: 'resize', width: size } + : { type: 'resize', height: size } + const buf = await processBuffer( + content, + [resizeOperationOpts], + extension, + quality + ) + return buf + } +} diff --git a/test/integration/image-optimizer/app/.gitignore b/test/integration/image-optimizer/app/.gitignore new file mode 100644 index 0000000000000..259596b506cff --- /dev/null +++ b/test/integration/image-optimizer/app/.gitignore @@ -0,0 +1,3 @@ +node_modules +package.json +yarn.lock \ No newline at end of file diff --git a/test/integration/image-optimizer/next.config.js b/test/integration/image-optimizer/app/next.config.js similarity index 100% rename from test/integration/image-optimizer/next.config.js rename to test/integration/image-optimizer/app/next.config.js diff --git a/test/integration/image-optimizer/pages/index.js b/test/integration/image-optimizer/app/pages/index.js similarity index 100% rename from test/integration/image-optimizer/pages/index.js rename to test/integration/image-optimizer/app/pages/index.js diff --git a/test/integration/image-optimizer/public/animated.gif b/test/integration/image-optimizer/app/public/animated.gif similarity index 100% rename from test/integration/image-optimizer/public/animated.gif rename to test/integration/image-optimizer/app/public/animated.gif diff --git a/test/integration/image-optimizer/public/animated.png b/test/integration/image-optimizer/app/public/animated.png similarity index 100% rename from test/integration/image-optimizer/public/animated.png rename to test/integration/image-optimizer/app/public/animated.png diff --git a/test/integration/image-optimizer/public/animated.webp b/test/integration/image-optimizer/app/public/animated.webp similarity index 100% rename from test/integration/image-optimizer/public/animated.webp rename to test/integration/image-optimizer/app/public/animated.webp diff --git a/test/integration/image-optimizer/public/grayscale.png b/test/integration/image-optimizer/app/public/grayscale.png similarity index 100% rename from test/integration/image-optimizer/public/grayscale.png rename to test/integration/image-optimizer/app/public/grayscale.png diff --git a/test/integration/image-optimizer/public/test.bmp b/test/integration/image-optimizer/app/public/test.bmp similarity index 100% rename from test/integration/image-optimizer/public/test.bmp rename to test/integration/image-optimizer/app/public/test.bmp diff --git a/test/integration/image-optimizer/public/test.gif b/test/integration/image-optimizer/app/public/test.gif similarity index 100% rename from test/integration/image-optimizer/public/test.gif rename to test/integration/image-optimizer/app/public/test.gif diff --git a/test/integration/image-optimizer/public/test.ico b/test/integration/image-optimizer/app/public/test.ico similarity index 100% rename from test/integration/image-optimizer/public/test.ico rename to test/integration/image-optimizer/app/public/test.ico diff --git a/test/integration/image-optimizer/public/test.jpg b/test/integration/image-optimizer/app/public/test.jpg similarity index 100% rename from test/integration/image-optimizer/public/test.jpg rename to test/integration/image-optimizer/app/public/test.jpg diff --git a/test/integration/image-optimizer/public/test.png b/test/integration/image-optimizer/app/public/test.png similarity index 100% rename from test/integration/image-optimizer/public/test.png rename to test/integration/image-optimizer/app/public/test.png diff --git a/test/integration/image-optimizer/public/test.svg b/test/integration/image-optimizer/app/public/test.svg similarity index 100% rename from test/integration/image-optimizer/public/test.svg rename to test/integration/image-optimizer/app/public/test.svg diff --git a/test/integration/image-optimizer/public/test.tiff b/test/integration/image-optimizer/app/public/test.tiff similarity index 100% rename from test/integration/image-optimizer/public/test.tiff rename to test/integration/image-optimizer/app/public/test.tiff diff --git a/test/integration/image-optimizer/public/text.txt b/test/integration/image-optimizer/app/public/text.txt similarity index 100% rename from test/integration/image-optimizer/public/text.txt rename to test/integration/image-optimizer/app/public/text.txt diff --git a/test/integration/image-optimizer/test/detect-content-type.test.js b/test/integration/image-optimizer/test/detect-content-type.unit.test.js similarity index 70% rename from test/integration/image-optimizer/test/detect-content-type.test.js rename to test/integration/image-optimizer/test/detect-content-type.unit.test.js index 3902daf1c1fbb..bc2df8ba165a2 100644 --- a/test/integration/image-optimizer/test/detect-content-type.test.js +++ b/test/integration/image-optimizer/test/detect-content-type.unit.test.js @@ -1,25 +1,25 @@ /* eslint-env jest */ import { detectContentType } from '../../../../packages/next/dist/server/image-optimizer.js' -import { readFile } from 'fs/promises' +import { readFile } from 'fs-extra' import { join } from 'path' const getImage = (filepath) => readFile(join(__dirname, filepath)) describe('detectContentType', () => { it('should return jpg', async () => { - const buffer = await getImage('../public/test.jpg') + const buffer = await getImage('../app/public/test.jpg') expect(detectContentType(buffer)).toBe('image/jpeg') }) it('should return png', async () => { - const buffer = await getImage('../public/test.png') + const buffer = await getImage('../app/public/test.png') expect(detectContentType(buffer)).toBe('image/png') }) it('should return webp', async () => { - const buffer = await getImage('../public/animated.webp') + const buffer = await getImage('../app/public/animated.webp') expect(detectContentType(buffer)).toBe('image/webp') }) it('should return svg', async () => { - const buffer = await getImage('../public/test.svg') + const buffer = await getImage('../app/public/test.svg') expect(detectContentType(buffer)).toBe('image/svg+xml') }) }) diff --git a/test/integration/image-optimizer/test/get-max-age.test.js b/test/integration/image-optimizer/test/get-max-age.unit.test.js similarity index 100% rename from test/integration/image-optimizer/test/get-max-age.test.js rename to test/integration/image-optimizer/test/get-max-age.unit.test.js diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 6a8145b3272f3..9bde62dc650f2 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -1,4 +1,5 @@ /* eslint-env jest */ +import execa from 'execa' import fs from 'fs-extra' import sizeOf from 'image-size' import { @@ -15,15 +16,18 @@ import { import isAnimated from 'next/dist/compiled/is-animated' import { join } from 'path' -jest.setTimeout(1000 * 60 * 2) +jest.setTimeout(1000 * 60 * 5) -const appDir = join(__dirname, '../') +const appDir = join(__dirname, '../app') const imagesDir = join(appDir, '.next', 'cache', 'images') const nextConfig = new File(join(appDir, 'next.config.js')) const largeSize = 1080 // defaults defined in server/config.ts +let nextOutput let appPort let app +const sharpMissingText = `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended` + async function fsToJson(dir, output = {}) { const files = await fs.readdir(dir) for (let file of files) { @@ -45,7 +49,7 @@ async function expectWidth(res, w) { expect(d.width).toBe(w) } -function runTests({ w, isDev, domains = [], ttl }) { +function runTests({ w, isDev, domains = [], ttl, isSharp }) { it('should return home page', async () => { const res = await fetchViaHTTP(appPort, '/', null, {}) expect(await res.text()).toMatch(/Image Optimizer Home/m) @@ -105,7 +109,7 @@ function runTests({ w, isDev, domains = [], ttl }) { expect(res.headers.get('etag')).toBeTruthy() const actual = await res.text() const expected = await fs.readFile( - join(__dirname, '..', 'public', 'test.svg'), + join(appDir, 'public', 'test.svg'), 'utf8' ) expect(actual).toMatch(expected) @@ -124,7 +128,7 @@ function runTests({ w, isDev, domains = [], ttl }) { expect(res.headers.get('etag')).toBeTruthy() const actual = await res.text() const expected = await fs.readFile( - join(__dirname, '..', 'public', 'test.ico'), + join(appDir, 'public', 'test.ico'), 'utf8' ) expect(actual).toMatch(expected) @@ -541,26 +545,30 @@ function runTests({ w, isDev, domains = [], ttl }) { await expectWidth(res, 400) }) - it('should not change the color type of a png', async () => { - // https://github.com/vercel/next.js/issues/22929 - // A grayscaled PNG with transparent pixels. - const query = { url: '/grayscale.png', w: largeSize, 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') - expect(res.headers.get('Cache-Control')).toBe( - `public, max-age=0, must-revalidate` - ) - expect(res.headers.get('Vary')).toBe('Accept') + if (!isSharp) { + // this checks for specific color type output by squoosh + // which differs in sharp + it('should not change the color type of a png', async () => { + // https://github.com/vercel/next.js/issues/22929 + // A grayscaled PNG with transparent pixels. + const query = { url: '/grayscale.png', w: largeSize, 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') + expect(res.headers.get('Cache-Control')).toBe( + `public, max-age=0, must-revalidate` + ) + expect(res.headers.get('Vary')).toBe('Accept') - const png = await res.buffer() + const png = await res.buffer() - // Read the color type byte (offset 9 + magic number 16). - // http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html - const colorType = png.readUIntBE(25, 1) - expect(colorType).toBe(4) - }) + // Read the color type byte (offset 9 + magic number 16). + // http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html + const colorType = png.readUIntBE(25, 1) + expect(colorType).toBe(4) + }) + } it('should set cache-control to immutable for static images', async () => { if (!isDev) { @@ -617,6 +625,16 @@ function runTests({ w, isDev, domains = [], ttl }) { const json1 = await fsToJson(imagesDir) expect(Object.keys(json1).length).toBe(1) }) + + if (isDev || isSharp) { + it('should not have sharp missing warning', () => { + expect(nextOutput).not.toContain(sharpMissingText) + }) + } else { + it('should have sharp missing warning', () => { + expect(nextOutput).toContain(sharpMissingText) + }) + } } describe('Image Optimizer', () => { @@ -782,81 +800,6 @@ describe('Image Optimizer', () => { 'image-optimization-test.vercel.app', ] - describe('dev support w/o next.config.js', () => { - const size = 384 // 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, domains: [] }) - }) - - describe('dev support with next.config.js', () => { - const size = 64 - beforeAll(async () => { - const json = JSON.stringify({ - images: { - deviceSizes: [largeSize], - imageSizes: [size], - domains, - }, - }) - 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, domains }) - }) - - describe('Server support w/o next.config.js', () => { - const size = 384 // defaults defined in server/config.ts - beforeAll(async () => { - await nextBuild(appDir) - appPort = await findPort() - app = await nextStart(appDir, appPort) - }) - afterAll(async () => { - await killApp(app) - await fs.remove(imagesDir) - }) - - runTests({ w: size, isDev: false, domains: [] }) - }) - - describe('Server support with next.config.js', () => { - const size = 128 - beforeAll(async () => { - const json = JSON.stringify({ - images: { - deviceSizes: [size, largeSize], - domains, - }, - }) - 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, domains }) - }) - describe('Server support for minimumCacheTTL in next.config.js', () => { const size = 96 // defaults defined in server/config.ts const ttl = 5 // super low ttl in seconds @@ -866,10 +809,15 @@ describe('Image Optimizer', () => { minimumCacheTTL: ttl, }, }) + nextOutput = '' nextConfig.replace('{ /* replaceme */ }', json) await nextBuild(appDir) appPort = await findPort() - app = await nextStart(appDir, appPort) + app = await nextStart(appDir, appPort, { + onStderr(msg) { + nextOutput += msg + }, + }) }) afterAll(async () => { await killApp(app) @@ -932,30 +880,6 @@ describe('Image Optimizer', () => { }) }) - describe('Serverless support with next.config.js', () => { - const size = 256 - beforeAll(async () => { - const json = JSON.stringify({ - target: 'experimental-serverless-trace', - images: { - deviceSizes: [size, largeSize], - domains, - }, - }) - 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, domains }) - }) - describe('dev support next.config.js cloudinary loader', () => { beforeAll(async () => { const json = JSON.stringify({ @@ -1045,4 +969,143 @@ describe('Image Optimizer', () => { await expectWidth(res, 8) }) }) + + const setupTests = (isSharp = false) => { + describe('dev support w/o next.config.js', () => { + const size = 384 // defaults defined in server/config.ts + beforeAll(async () => { + nextOutput = '' + appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + nextOutput += msg + }, + cwd: appDir, + }) + }) + afterAll(async () => { + await killApp(app) + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true, domains: [], isSharp }) + }) + + describe('dev support with next.config.js', () => { + const size = 64 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + deviceSizes: [largeSize], + imageSizes: [size], + domains, + }, + }) + nextOutput = '' + nextConfig.replace('{ /* replaceme */ }', json) + appPort = await findPort() + app = await launchApp(appDir, appPort, { + onStderr(msg) { + nextOutput += msg + }, + cwd: appDir, + }) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: true, domains, isSharp }) + }) + + describe('Server support w/o next.config.js', () => { + const size = 384 // defaults defined in server/config.ts + beforeAll(async () => { + nextOutput = '' + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort, { + onStderr(msg) { + nextOutput += msg + }, + env: { + NEXT_SHARP_PATH: isSharp + ? require.resolve('sharp', { + paths: [join(appDir, 'node_modules')], + }) + : '', + }, + cwd: appDir, + }) + }) + afterAll(async () => { + await killApp(app) + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false, domains: [], isSharp }) + }) + + describe('Server support with next.config.js', () => { + const size = 128 + beforeAll(async () => { + const json = JSON.stringify({ + images: { + deviceSizes: [size, largeSize], + domains, + }, + }) + nextOutput = '' + nextConfig.replace('{ /* replaceme */ }', json) + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort, { + onStderr(msg) { + nextOutput += msg + }, + env: { + NEXT_SHARP_PATH: isSharp + ? require.resolve('sharp', { + paths: [join(appDir, 'node_modules')], + }) + : '', + }, + cwd: appDir, + }) + }) + afterAll(async () => { + await killApp(app) + nextConfig.restore() + await fs.remove(imagesDir) + }) + + runTests({ w: size, isDev: false, domains, isSharp }) + }) + } + + describe('with squoosh', () => { + setupTests() + }) + + describe('with sharp', () => { + beforeAll(async () => { + await execa('yarn', ['init', '-y'], { + cwd: appDir, + stdio: 'inherit', + }) + await execa('yarn', ['add', 'sharp'], { + cwd: appDir, + stdio: 'inherit', + }) + }) + afterAll(async () => { + await fs.remove(join(appDir, 'node_modules')) + await fs.remove(join(appDir, 'yarn.lock')) + await fs.remove(join(appDir, 'package.json')) + }) + + setupTests(true) + }) }) diff --git a/yarn.lock b/yarn.lock index 74beced370355..d2fb8074cbb84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4316,6 +4316,13 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/sharp@0.28.4": + version "0.28.4" + resolved "https://registry.yarnpkg.com/@types/sharp/-/sharp-0.28.4.tgz#7afdcf979069ddc68a915603fdaa412ada93f833" + integrity sha512-vfz+RlJU5FgXyyf9w8wc+JwwT8MFI98NZr0272umQegrggAQhTwwb8pKZn0PTtd+j0crXkZDWw5ABG/i6Lu/Lw== + 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"