diff --git a/demo-v5/netlify.toml b/demo-v5/netlify.toml index 3da6b98b..750382fd 100644 --- a/demo-v5/netlify.toml +++ b/demo-v5/netlify.toml @@ -1,7 +1,7 @@ [build] command = "npm run build" publish = "public/" -environment = { GATSBY_CLOUD_IMAGE_CDN = "true" } +environment = { NETLIFY_IMAGE_CDN = "true" } ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ..; fi;" [[plugins]] @@ -9,3 +9,6 @@ package = "../plugin/src/index.ts" [[plugins]] package = "@netlify/plugin-local-install-core" + +[images] +remote_images = ['https://images.unsplash.com/*'] diff --git a/demo/netlify.toml b/demo/netlify.toml index 3da6b98b..493cf409 100644 --- a/demo/netlify.toml +++ b/demo/netlify.toml @@ -1,7 +1,7 @@ [build] command = "npm run build" publish = "public/" -environment = { GATSBY_CLOUD_IMAGE_CDN = "true" } +environment = { NETLIFY_IMAGE_CDN = "true" } ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ..; fi;" [[plugins]] @@ -9,3 +9,6 @@ package = "../plugin/src/index.ts" [[plugins]] package = "@netlify/plugin-local-install-core" + +[images] +remote_images = ['https://images.unsplash.com/*'] \ No newline at end of file diff --git a/docs/image-cdn.md b/docs/image-cdn.md index 22f7f38f..36fc6fcd 100644 --- a/docs/image-cdn.md +++ b/docs/image-cdn.md @@ -1,51 +1,73 @@ # Gatsby Image CDN on Netlify -Gatsby Image CDN is a new feature available in the prerelease version of Gatsby. -Instead of downloading and processing images at build time, it defers processing -until request time. This can greatly improve build times for sites with remote -images, such as those that use a CMS. Netlify includes full support for Image -CDN, on all plans. - -When using the image CDN, Gatsby generates URLs of the form -`/_gatsby/image/...`. On Netlify, these are served by a -[builder function](https://docs.netlify.com/configure-builds/on-demand-builders/), -powered by [sharp](https://sharp.pixelplumbing.com/) and Nuxt's -[ipx image server](https://github.com/unjs/ipx/). It supports all image formats -supported by Gatsby, including AVIF and WebP. - -On first load there will be a one-time delay while the image is resized, but -subsequent requests will be super-fast as they are served from the edge cache. +Gatsby Image CDN is a feature available since Gatsby v4.10.0. Instead of +downloading and processing images at build time, it defers processing until +request time. This can greatly improve build times for sites with remote images, +such as those that use a CMS. Netlify includes full support for Image CDN, on +all plans. ## Enabling the Image CDN -To enable the Image CDN during the beta period, you should set the environment -variable `GATSBY_CLOUD_IMAGE_CDN` to `true`. +To enable the Image CDN, you should set the environment variable +`NETLIFY_IMAGE_CDN` to `true`. You will also need to declare allowed image URL +patterns in `netlify.toml`: -Image CDN currently requires the beta version of Gatsby. This can be installed -using the `next` tag: +```toml +[build.environment] +NETLIFY_IMAGE_CDN = "true" -```shell -npm install gatsby@next gatsby-plugin-image@next gatsby-plugin-sharp@next gatsby-transformer-sharp@next +[images] +remote_images = [ + 'https://example1.com/*', + 'https://example2.com/*' +] ``` -Currently Image CDN supports Contentful and WordPress, and these source plugins -should also be installed using the `next` tag: +Exact URL patterns to use will depend on CMS you use and possibly your +configuration of it. -```shell -npm install gatsby-source-wordpress@next -``` +- `gatsby-source-contentful`: -or + ```toml + [images] + remote_images = [ + # is specified in the `spaceId` option for the + # gatsby-source-contentful plugin in your gatsby-config file. + "https://images.ctfassets.net//*" + ] + ``` -```shell -npm install gatsby-source-contentful@next -``` +- `gatsby-source-drupal`: + + ```toml + [images] + remote_images = [ + # is speciafied in the `baseUrl` option for the + # gatsby-source-drupal plugin in your gatsby-config file. + "/*" + ] + ``` + +- `gatsby-source-wordpress`: + + ```toml + [images] + remote_images = [ + # is specified in the `url` option for the + # gatsby-source-wordpress plugin in your gatsby-config file. + # There is no need to include `/graphql in the path here` + "/*" + ] + ``` -Gatsby will be adding support to more source plugins during the beta period. -These should work automatically as soon as they are added. +Above examples are the most likely ones to be needed. However if you configure +your CMS to host assets on different domain or path, you might need to adjust +the patterns accordingly. -## Using the Image CDN +## How it works -Your GraphQL queries will need updating to use the image CDN. The details vary -depending on the source plugin. For more details see -[the Gatsby docs](https://support.gatsbyjs.com/hc/en-us/articles/4522338898579) +When using the Image CDN, Gatsby generates URLs of the form +`/_gatsby/image/...`. On Netlify, these are served by a function that translates +Gatsby Image CDN URLs into Netlify Image CDN compatible URL of the form +`/.netlify/images/...`. For more information about Netlify Image CDN, +documentation can be found [here](https://docs.netlify.com/image-cdn/overview). diff --git a/plugin/src/helpers/config.ts b/plugin/src/helpers/config.ts index 1da166eb..43aab3bd 100644 --- a/plugin/src/helpers/config.ts +++ b/plugin/src/helpers/config.ts @@ -382,4 +382,18 @@ export function shouldSkip(publishDir: string): boolean { return shouldSkipResult } + +export function checkNetlifyImageCdn({ + netlifyConfig, +}: { + netlifyConfig: NetlifyConfig +}): void { + /* eslint-disable no-param-reassign */ + const { NETLIFY_IMAGE_CDN } = netlifyConfig.build.environment + + if (NETLIFY_IMAGE_CDN === 'true') { + netlifyConfig.build.environment.GATSBY_CLOUD_IMAGE_CDN = 'true' + } + /* eslint-enable no-param-reassign */ +} /* eslint-enable max-lines */ diff --git a/plugin/src/helpers/functions.ts b/plugin/src/helpers/functions.ts index fc449f88..a5dd1d8a 100644 --- a/plugin/src/helpers/functions.ts +++ b/plugin/src/helpers/functions.ts @@ -79,9 +79,14 @@ export const setupImageCdn = async ({ constants: NetlifyPluginConstants netlifyConfig: NetlifyConfig }) => { - const { GATSBY_CLOUD_IMAGE_CDN } = netlifyConfig.build.environment - - if (GATSBY_CLOUD_IMAGE_CDN !== '1' && GATSBY_CLOUD_IMAGE_CDN !== 'true') { + const { GATSBY_CLOUD_IMAGE_CDN, NETLIFY_IMAGE_CDN } = + netlifyConfig.build.environment + + if ( + NETLIFY_IMAGE_CDN !== `true` && + GATSBY_CLOUD_IMAGE_CDN !== '1' && + GATSBY_CLOUD_IMAGE_CDN !== 'true' + ) { return } @@ -92,30 +97,64 @@ export const setupImageCdn = async ({ join(constants.INTERNAL_FUNCTIONS_SRC, '_ipx.ts'), ) + if (NETLIFY_IMAGE_CDN === `true`) { + await copyFile( + join(__dirname, '..', '..', 'src', 'templates', 'image.ts'), + join(constants.INTERNAL_FUNCTIONS_SRC, '__image.ts'), + ) + + netlifyConfig.redirects.push( + { + from: '/_gatsby/image/:unused/:unused2/:filename', + // eslint-disable-next-line id-length + query: { u: ':url', a: ':args', cd: ':cd' }, + to: '/.netlify/functions/__image/image_query_compat?url=:url&args=:args&cd=:cd', + status: 301, + force: true, + }, + { + from: '/_gatsby/image/*', + to: '/.netlify/functions/__image', + status: 200, + force: true, + }, + ) + } else if ( + GATSBY_CLOUD_IMAGE_CDN === '1' || + GATSBY_CLOUD_IMAGE_CDN === 'true' + ) { + netlifyConfig.redirects.push( + { + from: `/_gatsby/image/:unused/:unused2/:filename`, + // eslint-disable-next-line id-length + query: { u: ':url', a: ':args' }, + to: `/.netlify/builders/_ipx/image_query_compat/:args/:url/:filename`, + status: 301, + force: true, + }, + { + from: '/_gatsby/image/*', + to: '/.netlify/builders/_ipx', + status: 200, + force: true, + }, + ) + } + netlifyConfig.redirects.push( - { - from: `/_gatsby/image/:unused/:unused2/:filename`, - // eslint-disable-next-line id-length - query: { u: ':url', a: ':args' }, - to: `/.netlify/builders/_ipx/image_query_compat/:args/:url/:filename`, - status: 301, - }, { from: `/_gatsby/file/:unused/:filename`, // eslint-disable-next-line id-length query: { u: ':url' }, to: `/.netlify/functions/_ipx/file_query_compat/:url/:filename`, status: 301, - }, - { - from: '/_gatsby/image/*', - to: '/.netlify/builders/_ipx', - status: 200, + force: true, }, { from: '/_gatsby/file/*', to: '/.netlify/functions/_ipx', status: 200, + force: true, }, ) } diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 4ee696c0..219c90e6 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -15,6 +15,7 @@ import { modifyConfig, shouldSkipBundlingDatastore, shouldSkip, + checkNetlifyImageCdn, } from './helpers/config' import { modifyFiles } from './helpers/files' import { deleteFunctions, writeFunctions } from './helpers/functions' @@ -42,6 +43,8 @@ export async function onPreBuild({ await restoreCache({ utils, publish: PUBLISH_DIR }) await checkConfig({ utils, netlifyConfig }) + + await checkNetlifyImageCdn({ netlifyConfig }) } export async function onBuild({ diff --git a/plugin/src/templates/image.ts b/plugin/src/templates/image.ts new file mode 100644 index 00000000..aa7721e9 --- /dev/null +++ b/plugin/src/templates/image.ts @@ -0,0 +1,88 @@ +import { Buffer } from 'buffer' + +import { Handler } from '@netlify/functions' + +type Event = Parameters[0] + +function generateURLFromQueryParamsPath(uParam, cdParam, argsParam) { + try { + const newURL = new URL('.netlify/images', 'https://example.com') + newURL.searchParams.set('url', uParam) + newURL.searchParams.set('cd', cdParam) + + const aParams = new URLSearchParams(argsParam) + aParams.forEach((value, key) => { + newURL.searchParams.set(key, value) + }) + + return newURL.pathname + newURL.search + } catch (error) { + console.error('Error constructing URL:', error) + return null + } +} + +function generateURLFromBase64EncodedPath(path) { + const [, , , encodedUrl, encodedArgs] = path.split('/') + + const decodedUrl = Buffer.from(encodedUrl, 'base64').toString('utf8') + const decodedArgs = Buffer.from(encodedArgs, 'base64').toString('utf8') + + let sourceURL + try { + sourceURL = new URL(decodedUrl) + } catch (error) { + console.error('Decoded string is not a valid URL:', error) + return + } + + const newURL = new URL('.netlify/images', 'https://example.com') + newURL.searchParams.set('url', sourceURL.href) + + const aParams = new URLSearchParams(decodedArgs) + aParams.forEach((value, key) => { + newURL.searchParams.set(key, value) + }) + + return newURL.pathname + newURL.search +} + +// eslint-disable-next-line require-await +export const handler: Handler = async (event: Event) => { + const QUERY_PARAM_PATTERN = + /^\/\.netlify\/functions\/__image\/image_query_compat\/?$/i + + const { pathname } = new URL(event.rawUrl) + const match = pathname.match(QUERY_PARAM_PATTERN) + + let newURL + + if (match) { + // Extract the query parameters + const { + url: uParam, + cd: cdParam, + args: argsParam, + } = event.queryStringParameters + + newURL = generateURLFromQueryParamsPath(uParam, cdParam, argsParam) + } else { + newURL = generateURLFromBase64EncodedPath(pathname) + } + + const cachingHeaders = { + 'Cache-Control': 'public,max-age=31536000,immutable', + 'Netlify-CDN-Cache-Control': 'public,max-age=31536000,immutable', + 'Netlify-Vary': 'query', + } + + return newURL + ? { + statusCode: 301, + headers: { + Location: newURL, + ...cachingHeaders, + }, + } + : { statusCode: 400, body: 'Invalid request', headers: cachingHeaders } +}