Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for images.loaderFile config #41585

Merged
merged 14 commits into from Oct 21, 2022
25 changes: 25 additions & 0 deletions docs/api-reference/next/image.md
Expand Up @@ -111,6 +111,8 @@ const MyImage = (props) => {
}
```

Alternatively, you can use the [loaderFile](#loader-configuration) configuration in next.config.js to configure every instance of `next/image` in your application, without passing a prop.

### fill

A boolean that causes the image to fill the parent element instead of setting [`width`](#width) and [`height`](#height).
Expand Down Expand Up @@ -343,6 +345,29 @@ module.exports = {
}
```

### Loader Configuration

If you want to use a cloud provider to optimize images instead of using the Next.js built-in Image Optimization API, you can configure the `loaderFile` in your `next.config.js` like the following:

```js
module.exports = {
images: {
loader: 'custom',
loaderFile: './my/image/loader.js',
},
}
```

This must point to a file relative to the root of your Next.js application. The file must export a default function that returns a string, for example:

```js
export default function myImageLoader({ src, width, quality }) {
return `https://example.com/${src}?w=${width}&q=${quality || 75}`
}
```

Alternatively, you can use the [`loader` prop](#loader) to configure each instance of `next/image`.

## Advanced

The following configuration is for advanced use cases and is usually not necessary. If you choose to configure the properties below, you will override any changes to the Next.js defaults in future updates.
Expand Down
8 changes: 4 additions & 4 deletions docs/basic-features/image-optimization.md
Expand Up @@ -99,13 +99,13 @@ To protect your application from malicious users, you must define a list of remo

### Loaders

Note that in the [example earlier](#remote-images), a partial URL (`"/me.png"`) is provided for a remote image. This is possible because of the `next/image` [loader](/docs/api-reference/next/image.md#loader) architecture.
Note that in the [example earlier](#remote-images), a partial URL (`"/me.png"`) is provided for a remote image. This is possible because of the loader architecture.

A loader is a function that generates the URLs for your image. It modifies the provided `src`, and generates multiple URLs to request the image at different sizes. These multiple URLs are used in the automatic [srcset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) generation, so that visitors to your site will be served an image that is the right size for their viewport.

The default loader for Next.js applications uses the built-in Image Optimization API, which optimizes images from anywhere on the web, and then serves them directly from the Next.js web server. If you would like to serve your images directly from a CDN or image server, you can use one of the [built-in loaders](/docs/api-reference/next/image.md#built-in-loaders) or write your own with a few lines of JavaScript.
The default loader for Next.js applications uses the built-in Image Optimization API, which optimizes images from anywhere on the web, and then serves them directly from the Next.js web server. If you would like to serve your images directly from a CDN or image server, you can write your own loader function with a few lines of JavaScript.

Loaders can be defined per-image, or at the application level.
You can define a loader per-image with the [`loader` prop](/docs/api-reference/next/image.md#loader), or at the application level with the [`loaderFile` configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration).

### Priority

Expand Down Expand Up @@ -151,7 +151,7 @@ Because `next/image` is designed to guarantee good performance results, it canno
>
> If you are accessing images from a source without knowledge of the images' sizes, there are several things you can do:
>
> **Use `fill``**
> **Use `fill`**
>
> The [`fill`](/docs/api-reference/next/image#fill) prop allows your image to be sized by its parent element. Consider using CSS to give the image's parent element space on the page along [`sizes`](/docs/api-reference/next/image#sizes) prop to match any media query break points. You can also use [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) with `fill`, `contain`, or `cover`, and [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) to define how the image should occupy that space.
>
Expand Down
2 changes: 2 additions & 0 deletions errors/invalid-images-config.md
Expand Up @@ -21,6 +21,8 @@ module.exports = {
path: '/_next/image',
// loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom'
loader: 'default',
// file with `export default function loader({src, width, quality})`
loaderFile: '',
// disable static imports for image files
disableStaticImages: false,
// minimumCacheTTL is in seconds, must be integer 0 or more
Expand Down
6 changes: 6 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -866,6 +866,12 @@ export default async function getBaseWebpackConfig(
}
: undefined),

...(config.images.loaderFile
? {
'next/dist/shared/lib/image-loader': config.images.loaderFile,
}
: undefined),

next: NEXT_PROJECT_ROOT,

...(hasServerComponents
Expand Down
99 changes: 23 additions & 76 deletions packages/next/client/image.tsx
Expand Up @@ -16,6 +16,8 @@ import {
} from '../shared/lib/image-config'
import { ImageConfigContext } from '../shared/lib/image-config-context'
import { warnOnce } from '../shared/lib/utils'
// @ts-ignore - This is replaced by webpack alias
import defaultLoader from 'next/dist/shared/lib/image-loader'

const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
const allImgs = new Map<
Expand Down Expand Up @@ -468,70 +470,6 @@ const ImageElement = ({
)
}

function defaultLoader({
config,
src,
width,
quality,
}: ImageLoaderPropsWithConfig): string {
if (process.env.NODE_ENV !== 'production') {
const missingValues = []

// these should always be provided but make sure they are
if (!src) missingValues.push('src')
if (!width) missingValues.push('width')

if (missingValues.length > 0) {
throw new Error(
`Next Image Optimization requires ${missingValues.join(
', '
)} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify(
{ src, width, quality }
)}`
)
}

if (src.startsWith('//')) {
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)`
)
}

if (!src.startsWith('/') && (config.domains || config.remotePatterns)) {
let parsedSrc: URL
try {
parsedSrc = new URL(src)
} catch (err) {
console.error(err)
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)`
)
}

if (process.env.NODE_ENV !== 'test') {
// We use dynamic require because this should only error in development
const { hasMatch } = require('../shared/lib/match-remote-pattern')
if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) {
throw new Error(
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`
)
}
}
}
}

if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) {
// Special case to make svg serve as-is to avoid proxying
// through the built-in Image Optimization API.
return src
}

return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${
quality || 75
}`
}

export default function Image({
src,
sizes,
Expand Down Expand Up @@ -559,20 +497,29 @@ export default function Image({
}, [configContext])

let rest: Partial<ImageProps> = all
let loader: ImageLoaderWithConfig = rest.loader || defaultLoader

let loader: ImageLoaderWithConfig = defaultLoader
if ('loader' in rest) {
if (rest.loader) {
const customImageLoader = rest.loader
loader = (obj) => {
const { config: _, ...opts } = obj
// The config object is internal only so we must
// not pass it to the user-defined loader()
return customImageLoader(opts)
}
// Remove property so it's not spread on <img> element
delete rest.loader

if ('__next_img_default' in loader) {
// This special value indicates that the user
// didn't define a "loader" prop or config.
if (config.loader === 'custom') {
throw new Error(
`Image with src "${src}" is missing "loader" prop.` +
`\nRead more: https://nextjs.org/docs/messages/next-image-missing-loader`
)
}
} else {
// The user defined a "loader" prop or config.
// Since the config object is internal only, we
// must not pass it to the user-defined "loader".
const customImageLoader = loader as ImageLoader
loader = (obj) => {
const { config: _, ...opts } = obj
return customImageLoader(opts)
}
// Remove property so it's not spread on <img>
delete rest.loader
}

let staticSrc = ''
Expand Down
4 changes: 4 additions & 0 deletions packages/next/server/config-schema.ts
Expand Up @@ -585,6 +585,10 @@ const configSchema = {
enum: VALID_LOADERS as any,
type: 'string',
},
loaderFile: {
minLength: 1,
type: 'string',
},
minimumCacheTTL: {
type: 'number',
},
Expand Down
32 changes: 26 additions & 6 deletions packages/next/server/config.ts
@@ -1,4 +1,5 @@
import { basename, extname, relative, isAbsolute, resolve } from 'path'
import { existsSync } from 'fs'
import { basename, extname, join, relative, isAbsolute, resolve } from 'path'
import { pathToFileURL } from 'url'
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
Expand Down Expand Up @@ -76,7 +77,7 @@ export function setHttpClientAndAgentOptions(options: NextConfig) {
;(global as any).__NEXT_HTTPS_AGENT = new HttpsAgent(options.httpAgentOptions)
}

function assignDefaults(userConfig: { [key: string]: any }) {
function assignDefaults(dir: string, userConfig: { [key: string]: any }) {
const configFileName = userConfig.configFileName
if (typeof userConfig.exportTrailingSlash !== 'undefined') {
console.warn(
Expand Down Expand Up @@ -379,7 +380,7 @@ function assignDefaults(userConfig: { [key: string]: any }) {
images.path === imageConfigDefault.path
) {
throw new Error(
`Specified images.loader property (${images.loader}) also requires images.path property to be assigned to a URL prefix.\nSee more info here: https://nextjs.org/docs/api-reference/next/image#loader-configuration`
`Specified images.loader property (${images.loader}) also requires images.path property to be assigned to a URL prefix.\nSee more info here: https://nextjs.org/docs/api-reference/next/legacy/image#loader-configuration`
)
}

Expand All @@ -398,6 +399,22 @@ function assignDefaults(userConfig: { [key: string]: any }) {
images.path = `${result.basePath}${images.path}`
}

if (images.loaderFile) {
if (images.loader !== 'default' && images.loader !== 'custom') {
throw new Error(
`Specified images.loader property (${images.loader}) cannot be used with images.loaderFile property. Please set images.loader to "custom".`
)
}
const absolutePath = join(dir, images.loaderFile)
if (!existsSync(absolutePath)) {
throw new Error(
`Specified images.loaderFile does not exist at "${absolutePath}".`
)
}
images.loader = 'custom'
images.loaderFile = absolutePath
}

if (
images.minimumCacheTTL &&
(!Number.isInteger(images.minimumCacheTTL) || images.minimumCacheTTL < 0)
Expand Down Expand Up @@ -739,7 +756,7 @@ export default async function loadConfig(
let configFileName = 'next.config.js'

if (customConfig) {
return assignDefaults({
return assignDefaults(dir, {
configOrigin: 'server',
configFileName,
...customConfig,
Expand Down Expand Up @@ -818,7 +835,7 @@ export default async function loadConfig(
: canonicalBase) || ''
}

return assignDefaults({
return assignDefaults(dir, {
configOrigin: relative(dir, path),
configFile: path,
configFileName,
Expand Down Expand Up @@ -846,7 +863,10 @@ export default async function loadConfig(

// always call assignDefaults to ensure settings like
// reactRoot can be updated correctly even with no next.config.js
const completeConfig = assignDefaults(defaultConfig) as NextConfigComplete
const completeConfig = assignDefaults(
dir,
defaultConfig
) as NextConfigComplete
completeConfig.configFileName = configFileName
setHttpClientAndAgentOptions(completeConfig)
return completeConfig
Expand Down
8 changes: 6 additions & 2 deletions packages/next/shared/lib/image-config.ts
Expand Up @@ -49,12 +49,15 @@ export type ImageConfigComplete = {
/** @see [Image sizing documentation](https://nextjs.org/docs/basic-features/image-optimization#image-sizing) */
imageSizes: number[]

/** @see [Image loaders configuration](https://nextjs.org/docs/basic-features/image-optimization#loaders) */
/** @see [Image loaders configuration](https://nextjs.org/docs/api-reference/next/legacy/image#loader) */
loader: LoaderValue

/** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) */
/** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/legacy/image#loader-configuration) */
path: string

/** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) */
loaderFile: string

/**
* @see [Image domains configuration](https://nextjs.org/docs/api-reference/next/image#domains)
*/
Expand Down Expand Up @@ -89,6 +92,7 @@ export const imageConfigDefault: ImageConfigComplete = {
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
path: '/_next/image',
loader: 'default',
loaderFile: '',
domains: [],
disableStaticImages: false,
minimumCacheTTL: 60,
Expand Down
65 changes: 65 additions & 0 deletions packages/next/shared/lib/image-loader.ts
@@ -0,0 +1,65 @@
// TODO: change "any" to actual type
function defaultLoader({ config, src, width, quality }: any): string {
if (process.env.NODE_ENV !== 'production') {
const missingValues = []

// these should always be provided but make sure they are
if (!src) missingValues.push('src')
if (!width) missingValues.push('width')

if (missingValues.length > 0) {
throw new Error(
`Next Image Optimization requires ${missingValues.join(
', '
)} to be provided. Make sure you pass them as props to the \`next/image\` component. Received: ${JSON.stringify(
{ src, width, quality }
)}`
)
}

if (src.startsWith('//')) {
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)`
)
}

if (!src.startsWith('/') && (config.domains || config.remotePatterns)) {
let parsedSrc: URL
try {
parsedSrc = new URL(src)
} catch (err) {
console.error(err)
throw new Error(
`Failed to parse src "${src}" on \`next/image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)`
)
}

if (process.env.NODE_ENV !== 'test') {
// We use dynamic require because this should only error in development
const { hasMatch } = require('./match-remote-pattern')
if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) {
throw new Error(
`Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` +
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host`
)
}
}
}
}

if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) {
// Special case to make svg serve as-is to avoid proxying
// through the built-in Image Optimization API.
return src
}

return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${
quality || 75
}`
}

// We use this to determine if the import is the default loader
// or a custom loader defined by the user in next.config.js
defaultLoader.__next_img_default = true

export default defaultLoader
2 changes: 2 additions & 0 deletions test/integration/export-image-loader-legacy/next.config.js
@@ -0,0 +1,2 @@
// prettier-ignore
module.exports = { /* replaceme */ }