Repository: vercel/next.js
Pull request: fix(next/image): handle empty buffer and experimental flag for `skipMetadata`

As a follow up to PR https://github.com/vercel/next.js/pull/82538 this PR adds an experimental flag to optionally skip `sharp.metadata()` and rely only on the JS implementation for format detection.

We also detect an empty buffer input and exit early.

This PR is easier to review with whitespace disabled: https://github.com/vercel/next.js/pull/82569/files?w=1

Changed files:
filename: packages/next/src/server/config-schema.ts
patch: @@ -385,6 +385,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
         imgOptTimeoutInSeconds: z.number().int().optional(),
         imgOptMaxInputPixels: z.number().int().optional(),
         imgOptSequentialRead: z.boolean().optional().nullable(),
+        imgOptSkipMetadata: z.boolean().optional().nullable(),
         isrFlushToDisk: z.boolean().optional(),
         largePageDataBytes: z.number().optional(),
         linkNoTouchStart: z.boolean().optional(),

filename: packages/next/src/server/config-shared.ts
patch: @@ -473,6 +473,7 @@ export interface ExperimentalConfig {
   imgOptTimeoutInSeconds?: number
   imgOptMaxInputPixels?: number
   imgOptSequentialRead?: boolean | null
+  imgOptSkipMetadata?: boolean | null
   optimisticClientCache?: boolean
   /**
    * @deprecated use config.expireTime instead
@@ -1526,6 +1527,7 @@ export const defaultConfig = Object.freeze({
     imgOptTimeoutInSeconds: 7,
     imgOptMaxInputPixels: 268_402_689, // https://sharp.pixelplumbing.com/api-constructor#:~:text=%5Boptions.limitInputPixels%5D
     imgOptSequentialRead: null,
+    imgOptSkipMetadata: null,
     isrFlushToDisk: true,
     workerThreads: false,
     proxyTimeout: undefined,

filename: packages/next/src/server/image-optimizer.ts
patch: @@ -158,8 +158,13 @@ async function writeToCacheDir(
  * https://en.wikipedia.org/wiki/List_of_file_signatures
  */
 export async function detectContentType(
-  buffer: Buffer
+  buffer: Buffer,
+  skipMetadata: boolean | null | undefined,
+  concurrency?: number | null | undefined
 ): Promise<string | null> {
+  if (buffer.byteLength === 0) {
+    return null
+  }
   if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
     return JPEG
   }
@@ -239,8 +244,8 @@ export async function detectContentType(
     | undefined
   format = detector(buffer)
 
-  if (!format) {
-    const sharp = getSharp(null)
+  if (!format && !skipMetadata) {
+    const sharp = getSharp(concurrency)
     const meta = await sharp(buffer)
       .metadata()
       .catch((_) => null)
@@ -776,6 +781,7 @@ export async function imageOptimizer(
       | 'imgOptConcurrency'
       | 'imgOptMaxInputPixels'
       | 'imgOptSequentialRead'
+      | 'imgOptSkipMetadata'
       | 'imgOptTimeoutInSeconds'
     >
     images: Pick<
@@ -803,7 +809,11 @@ export async function imageOptimizer(
     getMaxAge(imageUpstream.cacheControl)
   )
 
-  const upstreamType = await detectContentType(upstreamBuffer)
+  const upstreamType = await detectContentType(
+    upstreamBuffer,
+    nextConfig.experimental.imgOptSkipMetadata,
+    nextConfig.experimental.imgOptConcurrency
+  )
 
   if (
     !upstreamType ||

filename: test/unit/image-optimizer/detect-content-type.test.ts
patch: @@ -5,79 +5,129 @@ import { join } from 'path'
 
 const getImage = (filepath) => readFile(join(__dirname, filepath))
 
-describe('detectContentType', () => {
-  it('should return jpg', async () => {
-    const buffer = await getImage('./images/test.jpg')
-    expect(await detectContentType(buffer)).toBe('image/jpeg')
-  })
-  it('should return png', async () => {
-    const buffer = await getImage('./images/test.png')
-    expect(await detectContentType(buffer)).toBe('image/png')
-  })
-  it('should return webp', async () => {
-    const buffer = await getImage('./images/animated.webp')
-    expect(await detectContentType(buffer)).toBe('image/webp')
-  })
-  it('should return svg', async () => {
-    const buffer = await getImage('./images/test.svg')
-    expect(await detectContentType(buffer)).toBe('image/svg+xml')
-  })
-  it('should return svg for inline svg', async () => {
-    const buffer = await getImage('./images/test-inline.svg')
-    expect(await detectContentType(buffer)).toBe('image/svg+xml')
-  })
-  it('should return svg when starts with space', async () => {
-    const buffer = Buffer.from(
-      ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
-    )
-    expect(await detectContentType(buffer)).toBe('image/svg+xml')
-  })
-  it('should return svg when starts with newline', async () => {
-    const buffer = Buffer.from(
-      '\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
-    )
-    expect(await detectContentType(buffer)).toBe('image/svg+xml')
-  })
-  it('should return svg when starts with tab', async () => {
-    const buffer = Buffer.from(
-      '\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
-    )
-    expect(await detectContentType(buffer)).toBe('image/svg+xml')
-  })
-  it('should return avif', async () => {
-    const buffer = await getImage('./images/test.avif')
-    expect(await detectContentType(buffer)).toBe('image/avif')
-  })
-  it('should return icon', async () => {
-    const buffer = await getImage('./images/test.ico')
-    expect(await detectContentType(buffer)).toBe('image/x-icon')
-  })
-  it('should return icns', async () => {
-    const buffer = await getImage('./images/test.icns')
-    expect(await detectContentType(buffer)).toBe('image/x-icns')
-  })
-  it('should return jxl', async () => {
-    const buffer = await getImage('./images/test.jxl')
-    expect(await detectContentType(buffer)).toBe('image/jxl')
-  })
-  it('should return jp2', async () => {
-    const buffer = await getImage('./images/test.jp2')
-    expect(await detectContentType(buffer)).toBe('image/jp2')
-  })
-  it('should return heic', async () => {
-    const buffer = await getImage('./images/test.heic')
-    expect(await detectContentType(buffer)).toBe('image/heic')
-  })
-  it('should return pdf', async () => {
-    const buffer = await getImage('./images/test.pdf')
-    expect(await detectContentType(buffer)).toBe('application/pdf')
-  })
-  it('should return tiff', async () => {
-    const buffer = await getImage('./images/test.tiff')
-    expect(await detectContentType(buffer)).toBe('image/tiff')
-  })
-  it('should return bmp', async () => {
-    const buffer = await getImage('./images/test.bmp')
-    expect(await detectContentType(buffer)).toBe('image/bmp')
-  })
-})
+describe.each([false, true])(
+  'detectContentType with imgOptSkipMetadata: %s',
+  (imgOptSkipMetadata) => {
+    it('should return null for empty buffer', async () => {
+      expect(await detectContentType(Buffer.alloc(0), imgOptSkipMetadata)).toBe(
+        null
+      )
+    })
+    it('should return null for unrecognized buffer', async () => {
+      expect(
+        await detectContentType(
+          Buffer.from([0xa, 0xb, 0xc]),
+          imgOptSkipMetadata
+        )
+      ).toBe(null)
+    })
+    it('should return jpg', async () => {
+      const buffer = await getImage('./images/test.jpg')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/jpeg'
+      )
+    })
+    it('should return png', async () => {
+      const buffer = await getImage('./images/test.png')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/png'
+      )
+    })
+    it('should return webp', async () => {
+      const buffer = await getImage('./images/animated.webp')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/webp'
+      )
+    })
+    it('should return svg', async () => {
+      const buffer = await getImage('./images/test.svg')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/svg+xml'
+      )
+    })
+    it('should return svg for inline svg', async () => {
+      const buffer = await getImage('./images/test-inline.svg')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/svg+xml'
+      )
+    })
+    it('should return svg when starts with space', async () => {
+      const buffer = Buffer.from(
+        ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+      )
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/svg+xml'
+      )
+    })
+    it('should return svg when starts with newline', async () => {
+      const buffer = Buffer.from(
+        '\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+      )
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/svg+xml'
+      )
+    })
+    it('should return svg when starts with tab', async () => {
+      const buffer = Buffer.from(
+        '\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
+      )
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/svg+xml'
+      )
+    })
+    it('should return avif', async () => {
+      const buffer = await getImage('./images/test.avif')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/avif'
+      )
+    })
+    it('should return icon', async () => {
+      const buffer = await getImage('./images/test.ico')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/x-icon'
+      )
+    })
+    it('should return icns', async () => {
+      const buffer = await getImage('./images/test.icns')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/x-icns'
+      )
+    })
+    it('should return jxl', async () => {
+      const buffer = await getImage('./images/test.jxl')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/jxl'
+      )
+    })
+    it('should return jp2', async () => {
+      const buffer = await getImage('./images/test.jp2')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/jp2'
+      )
+    })
+    it('should return heic', async () => {
+      const buffer = await getImage('./images/test.heic')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/heic'
+      )
+    })
+    it('should return pdf', async () => {
+      const buffer = await getImage('./images/test.pdf')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'application/pdf'
+      )
+    })
+    it('should return tiff', async () => {
+      const buffer = await getImage('./images/test.tiff')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/tiff'
+      )
+    })
+    it('should return bmp', async () => {
+      const buffer = await getImage('./images/test.bmp')
+      expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
+        'image/bmp'
+      )
+    })
+  }
+)