Skip to content

Commit

Permalink
fix: improve next.config.js validation for images prop (#46326)
Browse files Browse the repository at this point in the history
This PR removes the custom validation in favor of json schema
validation.

Previously Next.js would print warnings on schema errors and only throw
when custom validation failed (albeit with a nasty stack trace).

### Before

<img width="1193" alt="image"
src="https://user-images.githubusercontent.com/229881/221045942-b035092b-1236-4da6-b676-58e3adae030d.png">

### After

<img width="794" alt="image"
src="https://user-images.githubusercontent.com/229881/221046100-fc0041b3-8fa8-4938-bd99-d0e3d3c0fae6.png">

---------
  • Loading branch information
styfle authored Feb 24, 2023
1 parent 130ab59 commit 0b248f8
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 204 deletions.
20 changes: 16 additions & 4 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ const configSchema = {
type: 'string',
},
port: {
maxLength: 5,
type: 'string',
},
protocol: {
Expand All @@ -590,8 +591,10 @@ const configSchema = {
type: 'string',
},
},
required: ['hostname'] as any,
type: 'object',
},
maxItems: 50,
type: 'array',
},
unoptimized: {
Expand All @@ -610,9 +613,12 @@ const configSchema = {
},
deviceSizes: {
items: {
type: 'number',
type: 'integer',
minimum: 1,
maximum: 10000,
},
minItems: 1,
maxItems: 25,
type: 'array',
},
disableStaticImages: {
Expand All @@ -622,20 +628,25 @@ const configSchema = {
items: {
type: 'string',
},
maxItems: 50,
type: 'array',
},
formats: {
items: {
enum: ['image/avif', 'image/webp'], // automatic typing does not like enum
type: 'string',
} as any,
maxItems: 4,
type: 'array',
},
imageSizes: {
items: {
type: 'number',
type: 'integer',
minimum: 1,
maximum: 10000,
},
minItems: 1,
minItems: 0,
maxItems: 25,
type: 'array',
},
loader: {
Expand All @@ -648,7 +659,8 @@ const configSchema = {
type: 'string',
},
minimumCacheTTL: {
type: 'number',
type: 'integer',
minimum: 0,
},
path: {
minLength: 1,
Expand Down
212 changes: 23 additions & 189 deletions packages/next/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,10 @@ import {
NextConfig,
} from './config-shared'
import { loadWebpackHook } from './config-utils'
import {
ImageConfig,
imageConfigDefault,
VALID_LOADERS,
} from '../shared/lib/image-config'
import { ImageConfig, imageConfigDefault } from '../shared/lib/image-config'
import { loadEnvConfig } from '@next/env'
import { gte as semverGte } from 'next/dist/compiled/semver'
import { flushAndExit } from '../telemetry/flush-and-exit'

export { DomainLocale, NextConfig, normalizeConfig } from './config-shared'

Expand Down Expand Up @@ -350,128 +347,12 @@ function assignDefaults(
if (config.assetPrefix?.startsWith('http')) {
images.domains.push(new URL(config.assetPrefix).hostname)
}

if (images.domains.length > 50) {
throw new Error(
`Specified images.domains exceeds length of 50, received length (${images.domains.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

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(
', '
)}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

const remotePatterns = result?.images?.remotePatterns
if (remotePatterns) {
if (!Array.isArray(remotePatterns)) {
throw new Error(
`Specified images.remotePatterns should be an Array received ${typeof remotePatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (remotePatterns.length > 50) {
throw new Error(
`Specified images.remotePatterns exceeds length of 50, received length (${remotePatterns.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

const validProps = new Set(['protocol', 'hostname', 'pathname', 'port'])
const requiredProps = ['hostname']
const invalidPatterns = remotePatterns.filter(
(d: unknown) =>
!d ||
typeof d !== 'object' ||
Object.entries(d).some(
([k, v]) => !validProps.has(k) || typeof v !== 'string'
) ||
requiredProps.some((k) => !(k in d))
)
if (invalidPatterns.length > 0) {
throw new Error(
`Invalid images.remotePatterns values:\n${invalidPatterns
.map((item) => JSON.stringify(item))
.join(
'\n'
)}\n\nremotePatterns value must follow format { protocol: 'https', hostname: 'example.com', port: '', pathname: '/imgs/**' }.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

if (images.deviceSizes) {
const { deviceSizes } = images
if (!Array.isArray(deviceSizes)) {
throw new Error(
`Specified images.deviceSizes should be an Array received ${typeof deviceSizes}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (deviceSizes.length > 25) {
throw new Error(
`Specified images.deviceSizes exceeds length of 25, received length (${deviceSizes.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

const invalid = deviceSizes.filter((d: unknown) => {
return typeof d !== 'number' || d < 1 || d > 10000
})

if (invalid.length > 0) {
throw new Error(
`Specified images.deviceSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join(
', '
)}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}
if (images.imageSizes) {
const { imageSizes } = images
if (!Array.isArray(imageSizes)) {
throw new Error(
`Specified images.imageSizes should be an Array received ${typeof imageSizes}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (imageSizes.length > 25) {
throw new Error(
`Specified images.imageSizes exceeds length of 25, received length (${imageSizes.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

const invalid = imageSizes.filter((d: unknown) => {
return typeof d !== 'number' || d < 1 || d > 10000
})

if (invalid.length > 0) {
throw new Error(
`Specified images.imageSizes should be an Array of numbers that are between 1 and 10000, received invalid values (${invalid.join(
', '
)}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

if (!images.loader) {
images.loader = 'default'
}

if (!VALID_LOADERS.includes(images.loader)) {
throw new Error(
`Specified images.loader should be one of (${VALID_LOADERS.join(
', '
)}), received invalid value (${
images.loader
}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (
images.loader !== 'default' &&
images.loader !== 'custom' &&
Expand Down Expand Up @@ -512,69 +393,6 @@ function assignDefaults(
images.loader = 'custom'
images.loaderFile = absolutePath
}

if (
images.minimumCacheTTL &&
(!Number.isInteger(images.minimumCacheTTL) || images.minimumCacheTTL < 0)
) {
throw new Error(
`Specified images.minimumCacheTTL should be an integer 0 or more received (${images.minimumCacheTTL}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (images.formats) {
const { formats } = images
if (!Array.isArray(formats)) {
throw new Error(
`Specified images.formats should be an Array received ${typeof formats}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
if (formats.length < 1 || formats.length > 2) {
throw new Error(
`Specified images.formats must be length 1 or 2, received length (${formats.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

const invalid = formats.filter((f) => {
return f !== 'image/avif' && f !== 'image/webp'
})

if (invalid.length > 0) {
throw new Error(
`Specified images.formats should be an Array of mime type strings, received invalid values (${invalid.join(
', '
)}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

if (
typeof images.dangerouslyAllowSVG !== 'undefined' &&
typeof images.dangerouslyAllowSVG !== 'boolean'
) {
throw new Error(
`Specified images.dangerouslyAllowSVG should be a boolean received (${images.dangerouslyAllowSVG}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

if (
typeof images.contentSecurityPolicy !== 'undefined' &&
typeof images.contentSecurityPolicy !== 'string'
) {
throw new Error(
`Specified images.contentSecurityPolicy should be a string received (${images.contentSecurityPolicy}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}

const unoptimized = result?.images?.unoptimized
if (
typeof unoptimized !== 'undefined' &&
typeof unoptimized !== 'boolean'
) {
throw new Error(
`Specified images.unoptimized should be a boolean, received (${unoptimized}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config`
)
}
}

warnOptionHasBeenMovedOutOfExperimental(
Expand Down Expand Up @@ -945,21 +763,37 @@ export default async function loadConfig(
const validateResult = validateConfig(userConfig)

if (!silent && validateResult.errors) {
curLog.warn(`Invalid next.config.js options detected: `)

// Only load @segment/ajv-human-errors when invalid config is detected
const { AggregateAjvError } =
require('next/dist/compiled/@segment/ajv-human-errors') as typeof import('next/dist/compiled/@segment/ajv-human-errors')
const aggregatedAjvErrors = new AggregateAjvError(validateResult.errors, {
fieldLabels: 'js',
})

let shouldExit = false
let messages = [`Invalid ${configFileName} options detected: `]

for (const error of aggregatedAjvErrors) {
console.error(` - ${error.message}`)
messages.push(` ${error.message}`)
if (error.message.startsWith('The value at .images.')) {
shouldExit = true
}
}

console.error(
'\nSee more info here: https://nextjs.org/docs/messages/invalid-next-config'
messages.push(
'See more info here: https://nextjs.org/docs/messages/invalid-next-config'
)

if (shouldExit) {
for (const message of messages) {
curLog.error(message)
}
await flushAndExit(1)
} else {
for (const message of messages) {
curLog.warn(message)
}
}
}

if (Object.keys(userConfig).length === 0) {
Expand Down
11 changes: 11 additions & 0 deletions packages/next/src/telemetry/flush-and-exit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { traceGlobals } from '../trace/shared'

export async function flushAndExit(code: number) {
let telemetry = traceGlobals.get('telemetry') as
| InstanceType<typeof import('./storage').Telemetry>
| undefined
if (telemetry) {
await telemetry.flush()
}
process.exit(code)
}
Loading

0 comments on commit 0b248f8

Please sign in to comment.