Skip to content

Commit 6b3baa4

Browse files
kathmbeckpieh
andauthored
feat: netlify Image cdn support (#710)
* 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 <misiek.piechowiak@gmail.com> --------- Co-authored-by: Michal Piechowiak <misiek.piechowiak@gmail.com>
1 parent 7dd7783 commit 6b3baa4

File tree

7 files changed

+225
-53
lines changed

7 files changed

+225
-53
lines changed

demo-v5/netlify.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
[build]
22
command = "npm run build"
33
publish = "public/"
4-
environment = { GATSBY_CLOUD_IMAGE_CDN = "true" }
4+
environment = { NETLIFY_IMAGE_CDN = "true" }
55
ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ..; fi;"
66

77
[[plugins]]
88
package = "../plugin/src/index.ts"
99

1010
[[plugins]]
1111
package = "@netlify/plugin-local-install-core"
12+
13+
[images]
14+
remote_images = ['https://images.unsplash.com/*']

demo/netlify.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
[build]
22
command = "npm run build"
33
publish = "public/"
4-
environment = { GATSBY_CLOUD_IMAGE_CDN = "true" }
4+
environment = { NETLIFY_IMAGE_CDN = "true" }
55
ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ..; fi;"
66

77
[[plugins]]
88
package = "../plugin/src/index.ts"
99

1010
[[plugins]]
1111
package = "@netlify/plugin-local-install-core"
12+
13+
[images]
14+
remote_images = ['https://images.unsplash.com/*']

docs/image-cdn.md

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,73 @@
11
# Gatsby Image CDN on Netlify
22

3-
Gatsby Image CDN is a new feature available in the prerelease version of Gatsby.
4-
Instead of downloading and processing images at build time, it defers processing
5-
until request time. This can greatly improve build times for sites with remote
6-
images, such as those that use a CMS. Netlify includes full support for Image
7-
CDN, on all plans.
8-
9-
When using the image CDN, Gatsby generates URLs of the form
10-
`/_gatsby/image/...`. On Netlify, these are served by a
11-
[builder function](https://docs.netlify.com/configure-builds/on-demand-builders/),
12-
powered by [sharp](https://sharp.pixelplumbing.com/) and Nuxt's
13-
[ipx image server](https://github.com/unjs/ipx/). It supports all image formats
14-
supported by Gatsby, including AVIF and WebP.
15-
16-
On first load there will be a one-time delay while the image is resized, but
17-
subsequent requests will be super-fast as they are served from the edge cache.
3+
Gatsby Image CDN is a feature available since Gatsby v4.10.0. Instead of
4+
downloading and processing images at build time, it defers processing until
5+
request time. This can greatly improve build times for sites with remote images,
6+
such as those that use a CMS. Netlify includes full support for Image CDN, on
7+
all plans.
188

199
## Enabling the Image CDN
2010

21-
To enable the Image CDN during the beta period, you should set the environment
22-
variable `GATSBY_CLOUD_IMAGE_CDN` to `true`.
11+
To enable the Image CDN, you should set the environment variable
12+
`NETLIFY_IMAGE_CDN` to `true`. You will also need to declare allowed image URL
13+
patterns in `netlify.toml`:
2314

24-
Image CDN currently requires the beta version of Gatsby. This can be installed
25-
using the `next` tag:
15+
```toml
16+
[build.environment]
17+
NETLIFY_IMAGE_CDN = "true"
2618

27-
```shell
28-
npm install gatsby@next gatsby-plugin-image@next gatsby-plugin-sharp@next gatsby-transformer-sharp@next
19+
[images]
20+
remote_images = [
21+
'https://example1.com/*',
22+
'https://example2.com/*'
23+
]
2924
```
3025

31-
Currently Image CDN supports Contentful and WordPress, and these source plugins
32-
should also be installed using the `next` tag:
26+
Exact URL patterns to use will depend on CMS you use and possibly your
27+
configuration of it.
3328

34-
```shell
35-
npm install gatsby-source-wordpress@next
36-
```
29+
- `gatsby-source-contentful`:
3730

38-
or
31+
```toml
32+
[images]
33+
remote_images = [
34+
# <your-contentful-space-id> is specified in the `spaceId` option for the
35+
# gatsby-source-contentful plugin in your gatsby-config file.
36+
"https://images.ctfassets.net/<your-contentful-space-id>/*"
37+
]
38+
```
3939

40-
```shell
41-
npm install gatsby-source-contentful@next
42-
```
40+
- `gatsby-source-drupal`:
41+
42+
```toml
43+
[images]
44+
remote_images = [
45+
# <your-drupal-base-url> is speciafied in the `baseUrl` option for the
46+
# gatsby-source-drupal plugin in your gatsby-config file.
47+
"<your-drupal-base-url>/*"
48+
]
49+
```
50+
51+
- `gatsby-source-wordpress`:
52+
53+
```toml
54+
[images]
55+
remote_images = [
56+
# <your-wordpress-url> is specified in the `url` option for the
57+
# gatsby-source-wordpress plugin in your gatsby-config file.
58+
# There is no need to include `/graphql in the path here`
59+
"<your-wordpress-url>/*"
60+
]
61+
```
4362

44-
Gatsby will be adding support to more source plugins during the beta period.
45-
These should work automatically as soon as they are added.
63+
Above examples are the most likely ones to be needed. However if you configure
64+
your CMS to host assets on different domain or path, you might need to adjust
65+
the patterns accordingly.
4666

47-
## Using the Image CDN
67+
## How it works
4868

49-
Your GraphQL queries will need updating to use the image CDN. The details vary
50-
depending on the source plugin. For more details see
51-
[the Gatsby docs](https://support.gatsbyjs.com/hc/en-us/articles/4522338898579)
69+
When using the Image CDN, Gatsby generates URLs of the form
70+
`/_gatsby/image/...`. On Netlify, these are served by a function that translates
71+
Gatsby Image CDN URLs into Netlify Image CDN compatible URL of the form
72+
`/.netlify/images/...`. For more information about Netlify Image CDN,
73+
documentation can be found [here](https://docs.netlify.com/image-cdn/overview).

plugin/src/helpers/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,18 @@ export function shouldSkip(publishDir: string): boolean {
382382

383383
return shouldSkipResult
384384
}
385+
386+
export function checkNetlifyImageCdn({
387+
netlifyConfig,
388+
}: {
389+
netlifyConfig: NetlifyConfig
390+
}): void {
391+
/* eslint-disable no-param-reassign */
392+
const { NETLIFY_IMAGE_CDN } = netlifyConfig.build.environment
393+
394+
if (NETLIFY_IMAGE_CDN === 'true') {
395+
netlifyConfig.build.environment.GATSBY_CLOUD_IMAGE_CDN = 'true'
396+
}
397+
/* eslint-enable no-param-reassign */
398+
}
385399
/* eslint-enable max-lines */

plugin/src/helpers/functions.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,14 @@ export const setupImageCdn = async ({
7979
constants: NetlifyPluginConstants
8080
netlifyConfig: NetlifyConfig
8181
}) => {
82-
const { GATSBY_CLOUD_IMAGE_CDN } = netlifyConfig.build.environment
83-
84-
if (GATSBY_CLOUD_IMAGE_CDN !== '1' && GATSBY_CLOUD_IMAGE_CDN !== 'true') {
82+
const { GATSBY_CLOUD_IMAGE_CDN, NETLIFY_IMAGE_CDN } =
83+
netlifyConfig.build.environment
84+
85+
if (
86+
NETLIFY_IMAGE_CDN !== `true` &&
87+
GATSBY_CLOUD_IMAGE_CDN !== '1' &&
88+
GATSBY_CLOUD_IMAGE_CDN !== 'true'
89+
) {
8590
return
8691
}
8792

@@ -92,30 +97,64 @@ export const setupImageCdn = async ({
9297
join(constants.INTERNAL_FUNCTIONS_SRC, '_ipx.ts'),
9398
)
9499

100+
if (NETLIFY_IMAGE_CDN === `true`) {
101+
await copyFile(
102+
join(__dirname, '..', '..', 'src', 'templates', 'image.ts'),
103+
join(constants.INTERNAL_FUNCTIONS_SRC, '__image.ts'),
104+
)
105+
106+
netlifyConfig.redirects.push(
107+
{
108+
from: '/_gatsby/image/:unused/:unused2/:filename',
109+
// eslint-disable-next-line id-length
110+
query: { u: ':url', a: ':args', cd: ':cd' },
111+
to: '/.netlify/functions/__image/image_query_compat?url=:url&args=:args&cd=:cd',
112+
status: 301,
113+
force: true,
114+
},
115+
{
116+
from: '/_gatsby/image/*',
117+
to: '/.netlify/functions/__image',
118+
status: 200,
119+
force: true,
120+
},
121+
)
122+
} else if (
123+
GATSBY_CLOUD_IMAGE_CDN === '1' ||
124+
GATSBY_CLOUD_IMAGE_CDN === 'true'
125+
) {
126+
netlifyConfig.redirects.push(
127+
{
128+
from: `/_gatsby/image/:unused/:unused2/:filename`,
129+
// eslint-disable-next-line id-length
130+
query: { u: ':url', a: ':args' },
131+
to: `/.netlify/builders/_ipx/image_query_compat/:args/:url/:filename`,
132+
status: 301,
133+
force: true,
134+
},
135+
{
136+
from: '/_gatsby/image/*',
137+
to: '/.netlify/builders/_ipx',
138+
status: 200,
139+
force: true,
140+
},
141+
)
142+
}
143+
95144
netlifyConfig.redirects.push(
96-
{
97-
from: `/_gatsby/image/:unused/:unused2/:filename`,
98-
// eslint-disable-next-line id-length
99-
query: { u: ':url', a: ':args' },
100-
to: `/.netlify/builders/_ipx/image_query_compat/:args/:url/:filename`,
101-
status: 301,
102-
},
103145
{
104146
from: `/_gatsby/file/:unused/:filename`,
105147
// eslint-disable-next-line id-length
106148
query: { u: ':url' },
107149
to: `/.netlify/functions/_ipx/file_query_compat/:url/:filename`,
108150
status: 301,
109-
},
110-
{
111-
from: '/_gatsby/image/*',
112-
to: '/.netlify/builders/_ipx',
113-
status: 200,
151+
force: true,
114152
},
115153
{
116154
from: '/_gatsby/file/*',
117155
to: '/.netlify/functions/_ipx',
118156
status: 200,
157+
force: true,
119158
},
120159
)
121160
}

plugin/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
modifyConfig,
1616
shouldSkipBundlingDatastore,
1717
shouldSkip,
18+
checkNetlifyImageCdn,
1819
} from './helpers/config'
1920
import { modifyFiles } from './helpers/files'
2021
import { deleteFunctions, writeFunctions } from './helpers/functions'
@@ -42,6 +43,8 @@ export async function onPreBuild({
4243
await restoreCache({ utils, publish: PUBLISH_DIR })
4344

4445
await checkConfig({ utils, netlifyConfig })
46+
47+
await checkNetlifyImageCdn({ netlifyConfig })
4548
}
4649

4750
export async function onBuild({

plugin/src/templates/image.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Buffer } from 'buffer'
2+
3+
import { Handler } from '@netlify/functions'
4+
5+
type Event = Parameters<Handler>[0]
6+
7+
function generateURLFromQueryParamsPath(uParam, cdParam, argsParam) {
8+
try {
9+
const newURL = new URL('.netlify/images', 'https://example.com')
10+
newURL.searchParams.set('url', uParam)
11+
newURL.searchParams.set('cd', cdParam)
12+
13+
const aParams = new URLSearchParams(argsParam)
14+
aParams.forEach((value, key) => {
15+
newURL.searchParams.set(key, value)
16+
})
17+
18+
return newURL.pathname + newURL.search
19+
} catch (error) {
20+
console.error('Error constructing URL:', error)
21+
return null
22+
}
23+
}
24+
25+
function generateURLFromBase64EncodedPath(path) {
26+
const [, , , encodedUrl, encodedArgs] = path.split('/')
27+
28+
const decodedUrl = Buffer.from(encodedUrl, 'base64').toString('utf8')
29+
const decodedArgs = Buffer.from(encodedArgs, 'base64').toString('utf8')
30+
31+
let sourceURL
32+
try {
33+
sourceURL = new URL(decodedUrl)
34+
} catch (error) {
35+
console.error('Decoded string is not a valid URL:', error)
36+
return
37+
}
38+
39+
const newURL = new URL('.netlify/images', 'https://example.com')
40+
newURL.searchParams.set('url', sourceURL.href)
41+
42+
const aParams = new URLSearchParams(decodedArgs)
43+
aParams.forEach((value, key) => {
44+
newURL.searchParams.set(key, value)
45+
})
46+
47+
return newURL.pathname + newURL.search
48+
}
49+
50+
// eslint-disable-next-line require-await
51+
export const handler: Handler = async (event: Event) => {
52+
const QUERY_PARAM_PATTERN =
53+
/^\/\.netlify\/functions\/__image\/image_query_compat\/?$/i
54+
55+
const { pathname } = new URL(event.rawUrl)
56+
const match = pathname.match(QUERY_PARAM_PATTERN)
57+
58+
let newURL
59+
60+
if (match) {
61+
// Extract the query parameters
62+
const {
63+
url: uParam,
64+
cd: cdParam,
65+
args: argsParam,
66+
} = event.queryStringParameters
67+
68+
newURL = generateURLFromQueryParamsPath(uParam, cdParam, argsParam)
69+
} else {
70+
newURL = generateURLFromBase64EncodedPath(pathname)
71+
}
72+
73+
const cachingHeaders = {
74+
'Cache-Control': 'public,max-age=31536000,immutable',
75+
'Netlify-CDN-Cache-Control': 'public,max-age=31536000,immutable',
76+
'Netlify-Vary': 'query',
77+
}
78+
79+
return newURL
80+
? {
81+
statusCode: 301,
82+
headers: {
83+
Location: newURL,
84+
...cachingHeaders,
85+
},
86+
}
87+
: { statusCode: 400, body: 'Invalid request', headers: cachingHeaders }
88+
}

0 commit comments

Comments
 (0)