Skip to content

Commit

Permalink
feat: netlify Image cdn support (#710)
Browse files Browse the repository at this point in the history
* feat: image cdn support

* fix: package-lock

* fix: lint

* fix: dependencies

* feat: testing

* fix: update function

* fix: temp remove function

* fix: reorder redirects

* feat: base url encoded paths

* fix: eslint

* fix: update redirect

* fix: error handling

* fix: debug log

* fix: path match

* fix: more tweaks

* fix: split

* fix: url param

* fix: /functions

* fix: param pattern

* fix: properly unencode args

* fix: use force for redirects

* fix: generate __image lambda only if NETLIFY_IMAGE_CDN is set

* chore: drop dev/debug logs, skip unnecesary awaits

* feat: apply caching headers

* fix: add regular cache-control too

* chore: use Netlify Image CDN in v5 demo as well

* chore: update image-cdn docs

* fix: use force for all redirects

* fix: update docs

* docs: adjust wording for for contentful and drupal to match with wordpress example

* Update docs/image-cdn.md

Co-authored-by: Michal Piechowiak <[email protected]>

---------

Co-authored-by: Michal Piechowiak <[email protected]>
  • Loading branch information
kathmbeck and pieh authored Dec 1, 2023
1 parent 7dd7783 commit 6b3baa4
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 53 deletions.
5 changes: 4 additions & 1 deletion demo-v5/netlify.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
[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]]
package = "../plugin/src/index.ts"

[[plugins]]
package = "@netlify/plugin-local-install-core"

[images]
remote_images = ['https://images.unsplash.com/*']
5 changes: 4 additions & 1 deletion demo/netlify.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
[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]]
package = "../plugin/src/index.ts"

[[plugins]]
package = "@netlify/plugin-local-install-core"

[images]
remote_images = ['https://images.unsplash.com/*']
94 changes: 58 additions & 36 deletions docs/image-cdn.md
Original file line number Diff line number Diff line change
@@ -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 = [
# <your-contentful-space-id> is specified in the `spaceId` option for the
# gatsby-source-contentful plugin in your gatsby-config file.
"https://images.ctfassets.net/<your-contentful-space-id>/*"
]
```

```shell
npm install gatsby-source-contentful@next
```
- `gatsby-source-drupal`:

```toml
[images]
remote_images = [
# <your-drupal-base-url> is speciafied in the `baseUrl` option for the
# gatsby-source-drupal plugin in your gatsby-config file.
"<your-drupal-base-url>/*"
]
```

- `gatsby-source-wordpress`:

```toml
[images]
remote_images = [
# <your-wordpress-url> 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`
"<your-wordpress-url>/*"
]
```

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).
14 changes: 14 additions & 0 deletions plugin/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
69 changes: 54 additions & 15 deletions plugin/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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,
},
)
}
Expand Down
3 changes: 3 additions & 0 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
modifyConfig,
shouldSkipBundlingDatastore,
shouldSkip,
checkNetlifyImageCdn,
} from './helpers/config'
import { modifyFiles } from './helpers/files'
import { deleteFunctions, writeFunctions } from './helpers/functions'
Expand Down Expand Up @@ -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({
Expand Down
88 changes: 88 additions & 0 deletions plugin/src/templates/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Buffer } from 'buffer'

import { Handler } from '@netlify/functions'

type Event = Parameters<Handler>[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 }
}

0 comments on commit 6b3baa4

Please sign in to comment.