Skip to content

Commit 55f705f

Browse files
authored
fix(next/image): handle empty buffer and experimental flag for skipMetadata (#82569)
As a follow up to PR #82538 this PR adds an experimental flag to optionally skip `sharp.metadata()` and rely only on the JS implementation for format detection.
1 parent 2bca1fd commit 55f705f

File tree

4 files changed

+143
-80
lines changed

4 files changed

+143
-80
lines changed

packages/next/src/server/config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
385385
imgOptTimeoutInSeconds: z.number().int().optional(),
386386
imgOptMaxInputPixels: z.number().int().optional(),
387387
imgOptSequentialRead: z.boolean().optional().nullable(),
388+
imgOptSkipMetadata: z.boolean().optional().nullable(),
388389
isrFlushToDisk: z.boolean().optional(),
389390
largePageDataBytes: z.number().optional(),
390391
linkNoTouchStart: z.boolean().optional(),

packages/next/src/server/config-shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ export interface ExperimentalConfig {
473473
imgOptTimeoutInSeconds?: number
474474
imgOptMaxInputPixels?: number
475475
imgOptSequentialRead?: boolean | null
476+
imgOptSkipMetadata?: boolean | null
476477
optimisticClientCache?: boolean
477478
/**
478479
* @deprecated use config.expireTime instead
@@ -1526,6 +1527,7 @@ export const defaultConfig = Object.freeze({
15261527
imgOptTimeoutInSeconds: 7,
15271528
imgOptMaxInputPixels: 268_402_689, // https://sharp.pixelplumbing.com/api-constructor#:~:text=%5Boptions.limitInputPixels%5D
15281529
imgOptSequentialRead: null,
1530+
imgOptSkipMetadata: null,
15291531
isrFlushToDisk: true,
15301532
workerThreads: false,
15311533
proxyTimeout: undefined,

packages/next/src/server/image-optimizer.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,13 @@ async function writeToCacheDir(
158158
* https://en.wikipedia.org/wiki/List_of_file_signatures
159159
*/
160160
export async function detectContentType(
161-
buffer: Buffer
161+
buffer: Buffer,
162+
skipMetadata: boolean | null | undefined,
163+
concurrency?: number | null | undefined
162164
): Promise<string | null> {
165+
if (buffer.byteLength === 0) {
166+
return null
167+
}
163168
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
164169
return JPEG
165170
}
@@ -239,8 +244,8 @@ export async function detectContentType(
239244
| undefined
240245
format = detector(buffer)
241246

242-
if (!format) {
243-
const sharp = getSharp(null)
247+
if (!format && !skipMetadata) {
248+
const sharp = getSharp(concurrency)
244249
const meta = await sharp(buffer)
245250
.metadata()
246251
.catch((_) => null)
@@ -776,6 +781,7 @@ export async function imageOptimizer(
776781
| 'imgOptConcurrency'
777782
| 'imgOptMaxInputPixels'
778783
| 'imgOptSequentialRead'
784+
| 'imgOptSkipMetadata'
779785
| 'imgOptTimeoutInSeconds'
780786
>
781787
images: Pick<
@@ -803,7 +809,11 @@ export async function imageOptimizer(
803809
getMaxAge(imageUpstream.cacheControl)
804810
)
805811

806-
const upstreamType = await detectContentType(upstreamBuffer)
812+
const upstreamType = await detectContentType(
813+
upstreamBuffer,
814+
nextConfig.experimental.imgOptSkipMetadata,
815+
nextConfig.experimental.imgOptConcurrency
816+
)
807817

808818
if (
809819
!upstreamType ||

test/unit/image-optimizer/detect-content-type.test.ts

Lines changed: 126 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,79 +5,129 @@ import { join } from 'path'
55

66
const getImage = (filepath) => readFile(join(__dirname, filepath))
77

8-
describe('detectContentType', () => {
9-
it('should return jpg', async () => {
10-
const buffer = await getImage('./images/test.jpg')
11-
expect(await detectContentType(buffer)).toBe('image/jpeg')
12-
})
13-
it('should return png', async () => {
14-
const buffer = await getImage('./images/test.png')
15-
expect(await detectContentType(buffer)).toBe('image/png')
16-
})
17-
it('should return webp', async () => {
18-
const buffer = await getImage('./images/animated.webp')
19-
expect(await detectContentType(buffer)).toBe('image/webp')
20-
})
21-
it('should return svg', async () => {
22-
const buffer = await getImage('./images/test.svg')
23-
expect(await detectContentType(buffer)).toBe('image/svg+xml')
24-
})
25-
it('should return svg for inline svg', async () => {
26-
const buffer = await getImage('./images/test-inline.svg')
27-
expect(await detectContentType(buffer)).toBe('image/svg+xml')
28-
})
29-
it('should return svg when starts with space', async () => {
30-
const buffer = Buffer.from(
31-
' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
32-
)
33-
expect(await detectContentType(buffer)).toBe('image/svg+xml')
34-
})
35-
it('should return svg when starts with newline', async () => {
36-
const buffer = Buffer.from(
37-
'\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
38-
)
39-
expect(await detectContentType(buffer)).toBe('image/svg+xml')
40-
})
41-
it('should return svg when starts with tab', async () => {
42-
const buffer = Buffer.from(
43-
'\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
44-
)
45-
expect(await detectContentType(buffer)).toBe('image/svg+xml')
46-
})
47-
it('should return avif', async () => {
48-
const buffer = await getImage('./images/test.avif')
49-
expect(await detectContentType(buffer)).toBe('image/avif')
50-
})
51-
it('should return icon', async () => {
52-
const buffer = await getImage('./images/test.ico')
53-
expect(await detectContentType(buffer)).toBe('image/x-icon')
54-
})
55-
it('should return icns', async () => {
56-
const buffer = await getImage('./images/test.icns')
57-
expect(await detectContentType(buffer)).toBe('image/x-icns')
58-
})
59-
it('should return jxl', async () => {
60-
const buffer = await getImage('./images/test.jxl')
61-
expect(await detectContentType(buffer)).toBe('image/jxl')
62-
})
63-
it('should return jp2', async () => {
64-
const buffer = await getImage('./images/test.jp2')
65-
expect(await detectContentType(buffer)).toBe('image/jp2')
66-
})
67-
it('should return heic', async () => {
68-
const buffer = await getImage('./images/test.heic')
69-
expect(await detectContentType(buffer)).toBe('image/heic')
70-
})
71-
it('should return pdf', async () => {
72-
const buffer = await getImage('./images/test.pdf')
73-
expect(await detectContentType(buffer)).toBe('application/pdf')
74-
})
75-
it('should return tiff', async () => {
76-
const buffer = await getImage('./images/test.tiff')
77-
expect(await detectContentType(buffer)).toBe('image/tiff')
78-
})
79-
it('should return bmp', async () => {
80-
const buffer = await getImage('./images/test.bmp')
81-
expect(await detectContentType(buffer)).toBe('image/bmp')
82-
})
83-
})
8+
describe.each([false, true])(
9+
'detectContentType with imgOptSkipMetadata: %s',
10+
(imgOptSkipMetadata) => {
11+
it('should return null for empty buffer', async () => {
12+
expect(await detectContentType(Buffer.alloc(0), imgOptSkipMetadata)).toBe(
13+
null
14+
)
15+
})
16+
it('should return null for unrecognized buffer', async () => {
17+
expect(
18+
await detectContentType(
19+
Buffer.from([0xa, 0xb, 0xc]),
20+
imgOptSkipMetadata
21+
)
22+
).toBe(null)
23+
})
24+
it('should return jpg', async () => {
25+
const buffer = await getImage('./images/test.jpg')
26+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
27+
'image/jpeg'
28+
)
29+
})
30+
it('should return png', async () => {
31+
const buffer = await getImage('./images/test.png')
32+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
33+
'image/png'
34+
)
35+
})
36+
it('should return webp', async () => {
37+
const buffer = await getImage('./images/animated.webp')
38+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
39+
'image/webp'
40+
)
41+
})
42+
it('should return svg', async () => {
43+
const buffer = await getImage('./images/test.svg')
44+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
45+
'image/svg+xml'
46+
)
47+
})
48+
it('should return svg for inline svg', async () => {
49+
const buffer = await getImage('./images/test-inline.svg')
50+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
51+
'image/svg+xml'
52+
)
53+
})
54+
it('should return svg when starts with space', async () => {
55+
const buffer = Buffer.from(
56+
' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
57+
)
58+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
59+
'image/svg+xml'
60+
)
61+
})
62+
it('should return svg when starts with newline', async () => {
63+
const buffer = Buffer.from(
64+
'\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
65+
)
66+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
67+
'image/svg+xml'
68+
)
69+
})
70+
it('should return svg when starts with tab', async () => {
71+
const buffer = Buffer.from(
72+
'\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
73+
)
74+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
75+
'image/svg+xml'
76+
)
77+
})
78+
it('should return avif', async () => {
79+
const buffer = await getImage('./images/test.avif')
80+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
81+
'image/avif'
82+
)
83+
})
84+
it('should return icon', async () => {
85+
const buffer = await getImage('./images/test.ico')
86+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
87+
'image/x-icon'
88+
)
89+
})
90+
it('should return icns', async () => {
91+
const buffer = await getImage('./images/test.icns')
92+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
93+
'image/x-icns'
94+
)
95+
})
96+
it('should return jxl', async () => {
97+
const buffer = await getImage('./images/test.jxl')
98+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
99+
'image/jxl'
100+
)
101+
})
102+
it('should return jp2', async () => {
103+
const buffer = await getImage('./images/test.jp2')
104+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
105+
'image/jp2'
106+
)
107+
})
108+
it('should return heic', async () => {
109+
const buffer = await getImage('./images/test.heic')
110+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
111+
'image/heic'
112+
)
113+
})
114+
it('should return pdf', async () => {
115+
const buffer = await getImage('./images/test.pdf')
116+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
117+
'application/pdf'
118+
)
119+
})
120+
it('should return tiff', async () => {
121+
const buffer = await getImage('./images/test.tiff')
122+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
123+
'image/tiff'
124+
)
125+
})
126+
it('should return bmp', async () => {
127+
const buffer = await getImage('./images/test.bmp')
128+
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
129+
'image/bmp'
130+
)
131+
})
132+
}
133+
)

0 commit comments

Comments
 (0)