diff --git a/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts b/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts
index 1f814544b02a2..2a2f347365e50 100644
--- a/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts
+++ b/e2e-tests/adapters/cypress/e2e/remote-file.cy.ts
@@ -11,107 +11,116 @@ Cypress.on("uncaught:exception", err => {
const PATH_PREFIX = Cypress.env(`PATH_PREFIX`) || ``
-describe(
- `remote-file`,
+// there are multiple scenarios we want to test and ensure that custom image cdn url is used:
+// - child build process (SSG, Page Query)
+// - main build process (SSG, Page Context)
+// - query engine (SSR, Page Query)
+const configs = [
{
- retries: {
- runMode: 4,
- },
+ title: `remote-file (SSG, Page Query)`,
+ pagePath: `/routes/remote-file/`,
+ fileCDN: true,
+ placeholders: true,
+ },
+ {
+ title: `remote-file (SSG, Page Context)`,
+ pagePath: `/routes/remote-file-data-from-context/`,
+ fileCDN: true,
+ placeholders: true,
+ },
+ {
+ title: `remote-file (SSR, Page Query)`,
+ pagePath: `/routes/ssr/remote-file/`,
+ fileCDN: false,
+ placeholders: false,
},
- () => {
- beforeEach(() => {
- cy.visit(`/routes/remote-file/`).waitForRouteChange()
-
- // trigger intersection observer
- cy.scrollTo("top")
- cy.wait(200)
- cy.scrollTo("bottom", {
- duration: 600,
+]
+
+for (const config of configs) {
+ describe(
+ config.title,
+ {
+ retries: {
+ runMode: 4,
+ },
+ },
+ () => {
+ beforeEach(() => {
+ cy.visit(config.pagePath).waitForRouteChange()
+
+ // trigger intersection observer
+ cy.scrollTo("top")
+ cy.wait(200)
+ cy.scrollTo("bottom", {
+ duration: 600,
+ })
+ cy.wait(600)
})
- cy.wait(600)
- })
- async function testImages(images, expectations) {
- for (let i = 0; i < images.length; i++) {
- const expectation = expectations[i]
+ async function testImages(images, expectations) {
+ for (let i = 0; i < images.length; i++) {
+ const expectation = expectations[i]
- const url = images[i].currentSrc
+ const url = images[i].currentSrc
- const { href, origin } = new URL(url)
- const urlWithoutOrigin = href.replace(origin, ``)
+ const { href, origin } = new URL(url)
+ const urlWithoutOrigin = href.replace(origin, ``)
- // using Netlify Image CDN
- expect(urlWithoutOrigin).to.match(/^\/.netlify\/images/)
+ // using Netlify Image CDN
+ expect(urlWithoutOrigin).to.match(/^\/.netlify\/images/)
- const res = await fetch(url, {
- method: "HEAD",
- })
- expect(res.ok).to.be.true
-
- const expectedNaturalWidth =
- expectation.naturalWidth ?? expectation.width
- const expectedNaturalHeight =
- expectation.naturalHeight ?? expectation.height
-
- if (expectation.width) {
- expect(
- Math.ceil(images[i].getBoundingClientRect().width)
- ).to.be.equal(expectation.width)
- }
- if (expectation.height) {
- expect(
- Math.ceil(images[i].getBoundingClientRect().height)
- ).to.be.equal(expectation.height)
- }
- if (expectedNaturalWidth) {
- expect(Math.ceil(images[i].naturalWidth)).to.be.equal(
- expectedNaturalWidth
- )
- }
- if (expectedNaturalHeight) {
- expect(Math.ceil(images[i].naturalHeight)).to.be.equal(
- expectedNaturalHeight
- )
- }
- }
- }
-
- it(`should render correct dimensions`, () => {
- cy.get('[data-testid="public"]').then(async $urls => {
- const urls = Array.from(
- $urls.map((_, $url) => $url.getAttribute("href"))
- )
-
- for (const url of urls) {
- // using OSS implementation for publicURL for now
- expect(url).to.match(new RegExp(`^${PATH_PREFIX}/_gatsby/file`))
const res = await fetch(url, {
method: "HEAD",
})
expect(res.ok).to.be.true
+
+ const expectedNaturalWidth =
+ expectation.naturalWidth ?? expectation.width
+ const expectedNaturalHeight =
+ expectation.naturalHeight ?? expectation.height
+
+ if (expectation.width) {
+ expect(
+ Math.ceil(images[i].getBoundingClientRect().width)
+ ).to.be.equal(expectation.width)
+ }
+ if (expectation.height) {
+ expect(
+ Math.ceil(images[i].getBoundingClientRect().height)
+ ).to.be.equal(expectation.height)
+ }
+ if (expectedNaturalWidth) {
+ expect(Math.ceil(images[i].naturalWidth)).to.be.equal(
+ expectedNaturalWidth
+ )
+ }
+ if (expectedNaturalHeight) {
+ expect(Math.ceil(images[i].naturalHeight)).to.be.equal(
+ expectedNaturalHeight
+ )
+ }
}
- })
+ }
- cy.get(".resize").then({ timeout: 60000 }, async $imgs => {
- await testImages(Array.from($imgs), [
- {
- width: 100,
- height: 133,
- },
- {
- width: 100,
- height: 160,
- },
- {
- width: 100,
- height: 67,
- },
- ])
- })
+ it(`should render correct dimensions`, () => {
+ if (config.fileCDN) {
+ cy.get('[data-testid="public"]').then(async $urls => {
+ const urls = Array.from(
+ $urls.map((_, $url) => $url.getAttribute("href"))
+ )
+
+ for (const url of urls) {
+ // using OSS implementation for publicURL for now
+ expect(url).to.match(new RegExp(`^${PATH_PREFIX}/_gatsby/file`))
+ const res = await fetch(url, {
+ method: "HEAD",
+ })
+ expect(res.ok).to.be.true
+ }
+ })
+ }
- cy.get(".fixed img:not([aria-hidden=true])").then(
- { timeout: 60000 },
- async $imgs => {
+ cy.get(".resize").then({ timeout: 60000 }, async $imgs => {
await testImages(Array.from($imgs), [
{
width: 100,
@@ -126,70 +135,92 @@ describe(
height: 67,
},
])
- }
- )
+ })
- cy.get(".constrained img:not([aria-hidden=true])").then(
- { timeout: 60000 },
- async $imgs => {
- await testImages(Array.from($imgs), [
- {
- width: 300,
- height: 400,
- },
- {
- width: 300,
- height: 481,
- },
- {
- width: 300,
- height: 200,
- },
- ])
- }
- )
+ cy.get(".fixed img:not([aria-hidden=true])").then(
+ { timeout: 60000 },
+ async $imgs => {
+ await testImages(Array.from($imgs), [
+ {
+ width: 100,
+ height: 133,
+ },
+ {
+ width: 100,
+ height: 160,
+ },
+ {
+ width: 100,
+ height: 67,
+ },
+ ])
+ }
+ )
- cy.get(".full img:not([aria-hidden=true])").then(
- { timeout: 60000 },
- async $imgs => {
- await testImages(Array.from($imgs), [
- {
- naturalHeight: 1333,
- },
- {
- naturalHeight: 1603,
- },
- {
- naturalHeight: 666,
- },
- ])
+ cy.get(".constrained img:not([aria-hidden=true])").then(
+ { timeout: 60000 },
+ async $imgs => {
+ await testImages(Array.from($imgs), [
+ {
+ width: 300,
+ height: 400,
+ },
+ {
+ width: 300,
+ height: 481,
+ },
+ {
+ width: 300,
+ height: 200,
+ },
+ ])
+ }
+ )
+
+ cy.get(".full img:not([aria-hidden=true])").then(
+ { timeout: 60000 },
+ async $imgs => {
+ await testImages(Array.from($imgs), [
+ {
+ naturalHeight: 1333,
+ },
+ {
+ naturalHeight: 1603,
+ },
+ {
+ naturalHeight: 666,
+ },
+ ])
+ }
+ )
+ })
+
+ it(`should render a placeholder`, () => {
+ if (config.placeholders) {
+ cy.get(".fixed [data-placeholder-image]")
+ .first()
+ .should("have.css", "background-color", "rgb(232, 184, 8)")
+ cy.get(".constrained [data-placeholder-image]")
+ .first()
+ .should($el => {
+ expect($el.prop("tagName")).to.be.equal("IMG")
+ expect($el.prop("src")).to.contain("data:image/jpg;base64")
+ })
+ cy.get(".constrained_traced [data-placeholder-image]")
+ .first()
+ .should($el => {
+ // traced falls back to DOMINANT_COLOR
+ expect($el.prop("tagName")).to.be.equal("DIV")
+ expect($el).to.be.empty
+ })
}
- )
- })
-
- it(`should render a placeholder`, () => {
- cy.get(".fixed [data-placeholder-image]")
- .first()
- .should("have.css", "background-color", "rgb(232, 184, 8)")
- cy.get(".constrained [data-placeholder-image]")
- .first()
- .should($el => {
- expect($el.prop("tagName")).to.be.equal("IMG")
- expect($el.prop("src")).to.contain("data:image/jpg;base64")
- })
- cy.get(".constrained_traced [data-placeholder-image]")
- .first()
- .should($el => {
- // traced falls back to DOMINANT_COLOR
- expect($el.prop("tagName")).to.be.equal("DIV")
- expect($el).to.be.empty
- })
- cy.get(".full [data-placeholder-image]")
- .first()
- .should($el => {
- expect($el.prop("tagName")).to.be.equal("DIV")
- expect($el).to.be.empty
- })
- })
- }
-)
+ cy.get(".full [data-placeholder-image]")
+ .first()
+ .should($el => {
+ expect($el.prop("tagName")).to.be.equal("DIV")
+ expect($el).to.be.empty
+ })
+ })
+ }
+ )
+}
diff --git a/e2e-tests/adapters/gatsby-node.ts b/e2e-tests/adapters/gatsby-node.ts
index 656dfac22953f..3c876c4f8efe7 100644
--- a/e2e-tests/adapters/gatsby-node.ts
+++ b/e2e-tests/adapters/gatsby-node.ts
@@ -6,9 +6,53 @@ import { applyTrailingSlashOption } from "./utils"
const TRAILING_SLASH = (process.env.TRAILING_SLASH ||
`never`) as GatsbyConfig["trailingSlash"]
-export const createPages: GatsbyNode["createPages"] = ({
- actions: { createRedirect, createSlice },
+export const createPages: GatsbyNode["createPages"] = async ({
+ actions: { createPage, createRedirect, createSlice },
+ graphql,
}) => {
+ const { data: ImageCDNRemoteFileFromPageContextData } = await graphql(`
+ query ImageCDNGatsbyNode {
+ allMyRemoteFile {
+ nodes {
+ id
+ url
+ filename
+ publicUrl
+ resize(width: 100) {
+ height
+ width
+ src
+ }
+ fixed: gatsbyImage(
+ layout: FIXED
+ width: 100
+ placeholder: DOMINANT_COLOR
+ )
+ constrained: gatsbyImage(
+ layout: CONSTRAINED
+ width: 300
+ placeholder: BLURRED
+ )
+ constrained_traced: gatsbyImage(
+ layout: CONSTRAINED
+ width: 300
+ placeholder: TRACED_SVG
+ )
+ full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE)
+ }
+ }
+ }
+ `)
+
+ createPage({
+ path: applyTrailingSlashOption(
+ `/routes/remote-file-data-from-context/`,
+ TRAILING_SLASH
+ ),
+ component: path.resolve(`./src/templates/remote-file-from-context.jsx`),
+ context: ImageCDNRemoteFileFromPageContextData,
+ })
+
createRedirect({
fromPath: applyTrailingSlashOption("/redirect", TRAILING_SLASH),
toPath: applyTrailingSlashOption("/routes/redirect/hit", TRAILING_SLASH),
diff --git a/e2e-tests/adapters/src/pages/index.jsx b/e2e-tests/adapters/src/pages/index.jsx
index 9cbcccbe6ac45..d0a2cad54df27 100644
--- a/e2e-tests/adapters/src/pages/index.jsx
+++ b/e2e-tests/adapters/src/pages/index.jsx
@@ -39,6 +39,18 @@ const routes = [
text: "Client-Only Named Wildcard",
url: "/routes/client-only/named-wildcard/corinno/fenring",
},
+ {
+ text: "RemoteFile (ImageCDN) (SSG, Page Query)",
+ url: "/routes/remote-file",
+ },
+ {
+ text: "RemoteFile (ImageCDN) (SSG, Page Context)",
+ url: "/routes/remote-file-data-from-context",
+ },
+ {
+ text: "RemoteFile (ImageCDN) (SSR, Page Query)",
+ url: "/routes/ssr/remote-file",
+ },
]
const functions = [
diff --git a/e2e-tests/adapters/src/pages/routes/remote-file.jsx b/e2e-tests/adapters/src/pages/routes/remote-file.jsx
index f9f35966e4ddf..d82c8c5030651 100644
--- a/e2e-tests/adapters/src/pages/routes/remote-file.jsx
+++ b/e2e-tests/adapters/src/pages/routes/remote-file.jsx
@@ -44,7 +44,7 @@ const RemoteFile = ({ data }) => {
}
export const pageQuery = graphql`
- {
+ query SSGImageCDNPageQuery {
allMyRemoteFile {
nodes {
id
diff --git a/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx b/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx
new file mode 100644
index 0000000000000..a838f7948b5ee
--- /dev/null
+++ b/e2e-tests/adapters/src/pages/routes/ssr/remote-file.jsx
@@ -0,0 +1,87 @@
+import { graphql } from "gatsby"
+import React from "react"
+
+import { GatsbyImage } from "gatsby-plugin-image"
+import Layout from "../../../components/layout"
+
+const RemoteFile = ({ data }) => {
+ return (
+
+ {data.allMyRemoteFile.nodes.map(node => {
+ return (
+
+
+

+
+
+
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+export const pageQuery = graphql`
+ query SSRImageCDNPageQuery {
+ allMyRemoteFile {
+ nodes {
+ id
+ url
+ filename
+ # FILE_CDN is not supported in SSR/DSG yet
+ # publicUrl
+ resize(width: 100) {
+ height
+ width
+ src
+ }
+ fixed: gatsbyImage(
+ layout: FIXED
+ width: 100
+ # only NONE placeholder is supported in SSR/DSG
+ # placeholder: DOMINANT_COLOR
+ placeholder: NONE
+ )
+ constrained: gatsbyImage(
+ layout: CONSTRAINED
+ width: 300
+ # only NONE placeholder is supported in SSR/DSG
+ # placeholder: DOMINANT_COLOR
+ placeholder: NONE
+ )
+ constrained_traced: gatsbyImage(
+ layout: CONSTRAINED
+ width: 300
+ # only NONE placeholder is supported in SSR/DSG
+ # placeholder: DOMINANT_COLOR
+ placeholder: NONE
+ )
+ full: gatsbyImage(layout: FULL_WIDTH, width: 500, placeholder: NONE)
+ }
+ }
+ }
+`
+
+export default RemoteFile
diff --git a/e2e-tests/adapters/src/templates/remote-file-from-context.jsx b/e2e-tests/adapters/src/templates/remote-file-from-context.jsx
new file mode 100644
index 0000000000000..2e2d8af24496f
--- /dev/null
+++ b/e2e-tests/adapters/src/templates/remote-file-from-context.jsx
@@ -0,0 +1,45 @@
+import React from "react"
+
+import { GatsbyImage } from "gatsby-plugin-image"
+import Layout from "../components/layout"
+
+const RemoteFile = ({ pageContext: data }) => {
+ return (
+
+ {data.allMyRemoteFile.nodes.map(node => {
+ return (
+
+
+

+
+
+
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+export default RemoteFile
diff --git a/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts
new file mode 100644
index 0000000000000..957b8cc86c718
--- /dev/null
+++ b/packages/gatsby-adapter-netlify/src/__tests__/image-cdn-url-generator.ts
@@ -0,0 +1,116 @@
+import { generateImageUrl, generateImageArgs } from "../image-cdn-url-generator"
+
+describe(`generateImageUrl`, () => {
+ const source = {
+ url: `https://example.com/image.jpg`,
+ filename: `image.jpg`,
+ mimeType: `image/jpeg`,
+ internal: {
+ contentDigest: `1234`,
+ },
+ }
+
+ it(`should return an image based url`, () => {
+ expect(
+ generateImageUrl(source, {
+ width: 100,
+ height: 100,
+ cropFocus: `top`,
+ format: `webp`,
+ quality: 80,
+ })
+ ).toMatchInlineSnapshot(
+ `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage.jpg&cd=1234"`
+ )
+ })
+
+ it(`should handle special characters`, () => {
+ const source = {
+ url: `https://example.com/image-éà.jpg`,
+ filename: `image-éà.jpg`,
+ mimeType: `image/jpeg`,
+ internal: {
+ contentDigest: `1234`,
+ },
+ }
+
+ expect(
+ generateImageUrl(source, {
+ width: 100,
+ height: 100,
+ cropFocus: `top`,
+ format: `webp`,
+ quality: 80,
+ })
+ ).toMatchInlineSnapshot(
+ `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage-%C3%A9%C3%A0.jpg&cd=1234"`
+ )
+ })
+
+ it(`should handle spaces`, () => {
+ const source = {
+ url: `https://example.com/image test.jpg`,
+ filename: `image test.jpg`,
+ mimeType: `image/jpeg`,
+ internal: {
+ contentDigest: `1234`,
+ },
+ }
+
+ expect(
+ generateImageUrl(source, {
+ width: 100,
+ height: 100,
+ cropFocus: `top`,
+ format: `webp`,
+ quality: 80,
+ })
+ ).toMatchInlineSnapshot(
+ `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage+test.jpg&cd=1234"`
+ )
+ })
+
+ it(`should handle encoded urls`, () => {
+ const source = {
+ url: `https://example.com/image%20test.jpg`,
+ filename: `image test.jpg`,
+ mimeType: `image/jpeg`,
+ internal: {
+ contentDigest: `1234`,
+ },
+ }
+
+ expect(
+ generateImageUrl(source, {
+ width: 100,
+ height: 100,
+ cropFocus: `top`,
+ format: `webp`,
+ quality: 80,
+ })
+ ).toMatchInlineSnapshot(
+ `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage%2520test.jpg&cd=1234"`
+ )
+ })
+
+ it.each([
+ [`width`, `w`, 100],
+ [`height`, `h`, 50],
+ [`cropFocus`, `crop`, `center,right`],
+ [`format`, `fm`, `webp`],
+ [`quality`, `q`, 60],
+ ] as Array<[keyof ImageArgs, string, ImageArgs[keyof ImageArgs]]>)(
+ `should set %s in image args`,
+ (key, queryKey, value) => {
+ const url = new URL(
+ // @ts-ignore remove typings
+ `https://netlify.com${generateImageUrl(source, {
+ format: `webp`,
+ [key]: value,
+ })}`
+ )
+
+ expect(url.searchParams.get(queryKey)).toEqual(value.toString())
+ }
+ )
+})
diff --git a/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts b/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts
new file mode 100644
index 0000000000000..3594227bb8616
--- /dev/null
+++ b/packages/gatsby-adapter-netlify/src/image-cdn-url-generator.ts
@@ -0,0 +1,59 @@
+import type {
+ ImageCdnUrlGeneratorFn,
+ ImageCdnSourceImage,
+ ImageCdnTransformArgs,
+} from "gatsby"
+
+export function generateImageUrl(
+ source: ImageCdnSourceImage,
+ imageArgs: ImageCdnTransformArgs
+): string {
+ const placeholderOrigin = `http://netlify.com`
+ const imageParams = generateImageArgs(imageArgs)
+
+ const baseURL = new URL(`${placeholderOrigin}/.netlify/images`)
+
+ baseURL.search = imageParams.toString()
+ baseURL.searchParams.append(`url`, source.url)
+ baseURL.searchParams.append(`cd`, source.internal.contentDigest)
+
+ return `${baseURL.pathname}${baseURL.search}`
+}
+
+export function generateImageArgs({
+ width,
+ height,
+ format,
+ cropFocus,
+ quality,
+}: ImageCdnTransformArgs): URLSearchParams {
+ const params = new URLSearchParams()
+
+ if (width) {
+ params.append(`w`, width.toString())
+ }
+ if (height) {
+ params.append(`h`, height.toString())
+ }
+ if (cropFocus) {
+ params.append(`fit`, `crop`)
+ if (Array.isArray(cropFocus)) {
+ // For array of cropFocus values, append them as comma-separated string
+ params.append(`crop`, cropFocus.join(`,`))
+ } else {
+ params.append(`crop`, cropFocus)
+ }
+ }
+
+ if (format) {
+ params.append(`fm`, format)
+ }
+
+ if (quality) {
+ params.append(`q`, quality.toString())
+ }
+
+ return params
+}
+
+export default generateImageUrl as ImageCdnUrlGeneratorFn
diff --git a/packages/gatsby-adapter-netlify/src/index.ts b/packages/gatsby-adapter-netlify/src/index.ts
index 2e20a63a35508..e741758167612 100644
--- a/packages/gatsby-adapter-netlify/src/index.ts
+++ b/packages/gatsby-adapter-netlify/src/index.ts
@@ -11,6 +11,7 @@ interface INetlifyCacheUtils {
interface INetlifyAdapterOptions {
excludeDatastoreFromEngineFunction?: boolean
+ imageCDN?: boolean
}
let _cacheUtils: INetlifyCacheUtils | undefined
@@ -117,6 +118,16 @@ const createNetlifyAdapter: AdapterInit = options => {
excludeDatastoreFromEngineFunction = false
}
+ let useNetlifyImageCDN = options?.imageCDN
+ if (
+ typeof useNetlifyImageCDN === `undefined` &&
+ typeof process.env.NETLIFY_IMAGE_CDN !== `undefined`
+ ) {
+ useNetlifyImageCDN =
+ process.env.NETLIFY_IMAGE_CDN === `true` ||
+ process.env.NETLIFY_IMAGE_CDN === `1`
+ }
+
return {
excludeDatastoreFromEngineFunction,
deployURL,
@@ -128,6 +139,9 @@ const createNetlifyAdapter: AdapterInit = options => {
`gatsby-plugin-netlify-cache`,
`gatsby-plugin-netlify`,
],
+ imageCDNUrlGeneratorModulePath: useNetlifyImageCDN
+ ? require.resolve(`./image-cdn-url-generator`)
+ : undefined,
}
},
}
diff --git a/packages/gatsby-plugin-utils/src/index.ts b/packages/gatsby-plugin-utils/src/index.ts
index 07d3c8b9418c5..82d3a8cec4b64 100644
--- a/packages/gatsby-plugin-utils/src/index.ts
+++ b/packages/gatsby-plugin-utils/src/index.ts
@@ -7,4 +7,9 @@ export * from "./has-feature"
export type {
IRemoteFileNodeInput,
IRemoteImageNodeInput,
+ // CustomImageCDNUrlGeneratorFn is custom to gatsby-plugin-utils
+ // but should be just ImageCDNUrlGeneratorFn publicly
+ CustomImageCdnUrlGeneratorFn as ImageCdnUrlGeneratorFn,
+ ImageCdnSourceImage,
+ ImageCdnTransformArgs,
} from "./polyfill-remote-file/types"
diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts
index d6afdb8ebc078..858ea8f9bcbdd 100644
--- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts
+++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts
@@ -16,9 +16,9 @@ export function shouldDispatchLocalFileServiceJob(): boolean {
export function shouldDispatchLocalImageServiceJob(): boolean {
return (
!(
+ global.__GATSBY?.imageCDNUrlGeneratorModulePath ||
process.env.GATSBY_CLOUD_IMAGE_CDN === `1` ||
- process.env.GATSBY_CLOUD_IMAGE_CDN === `true` ||
- process.env.NETLIFY_IMAGE_CDN === `true`
+ process.env.GATSBY_CLOUD_IMAGE_CDN === `true`
) && process.env.NODE_ENV === `production`
)
}
diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts
index 99cc86e69f7aa..6c58f2e4ac773 100644
--- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts
+++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts
@@ -93,3 +93,25 @@ export function isImage(node: {
return node.mimeType.startsWith(`image/`) && node.mimeType !== `image/svg+xml`
}
+
+export type ImageCdnTransformArgs = WidthOrHeight & {
+ format: string
+ cropFocus?: ImageCropFocus | Array
+ quality: number
+}
+
+interface IImageCdnSourceImage {
+ url: string
+ mimeType: string
+ filename: string
+ internal: { contentDigest: string }
+}
+
+// drop confusing double `II` from type/interface name
+export type ImageCdnSourceImage = IImageCdnSourceImage
+
+export type CustomImageCdnUrlGeneratorFn = (
+ source: ImageCdnSourceImage,
+ imageArgs: ImageCdnTransformArgs,
+ pathPrefix: string
+) => string
diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts
index 34448ddd3c24e..c74e778214986 100644
--- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts
+++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts
@@ -344,126 +344,3 @@ describe(`url-generator`, () => {
)
})
})
-
-describe(`generateImageUrlAlt`, () => {
- beforeEach(() => {
- process.env.NETLIFY_IMAGE_CDN = `true`
- })
-
- afterEach(() => {
- delete process.env.NETLIFY_IMAGE_CDN
- })
-
- const source = {
- url: `https://example.com/image.jpg`,
- filename: `image.jpg`,
- mimeType: `image/jpeg`,
- internal: {
- contentDigest: `1234`,
- },
- }
-
- it(`should return an image based url`, () => {
- expect(
- generateImageUrlAlt(source, {
- width: 100,
- height: 100,
- cropFocus: `top`,
- format: `webp`,
- quality: 80,
- })
- ).toMatchInlineSnapshot(
- `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage.jpg&cd=1234"`
- )
- })
-
- it(`should handle special characters`, () => {
- const source = {
- url: `https://example.com/image-éà.jpg`,
- filename: `image-éà.jpg`,
- mimeType: `image/jpeg`,
- internal: {
- contentDigest: `1234`,
- },
- }
-
- expect(
- generateImageUrlAlt(source, {
- width: 100,
- height: 100,
- cropFocus: `top`,
- format: `webp`,
- quality: 80,
- })
- ).toMatchInlineSnapshot(
- `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage-%C3%A9%C3%A0.jpg&cd=1234"`
- )
- })
-
- it(`should handle spaces`, () => {
- const source = {
- url: `https://example.com/image test.jpg`,
- filename: `image test.jpg`,
- mimeType: `image/jpeg`,
- internal: {
- contentDigest: `1234`,
- },
- }
-
- expect(
- generateImageUrlAlt(source, {
- width: 100,
- height: 100,
- cropFocus: `top`,
- format: `webp`,
- quality: 80,
- })
- ).toMatchInlineSnapshot(
- `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage+test.jpg&cd=1234"`
- )
- })
-
- it(`should handle encoded urls`, () => {
- const source = {
- url: `https://example.com/image%20test.jpg`,
- filename: `image test.jpg`,
- mimeType: `image/jpeg`,
- internal: {
- contentDigest: `1234`,
- },
- }
-
- expect(
- generateImageUrlAlt(source, {
- width: 100,
- height: 100,
- cropFocus: `top`,
- format: `webp`,
- quality: 80,
- })
- ).toMatchInlineSnapshot(
- `"/.netlify/images?w=100&h=100&fit=crop&crop=top&fm=webp&q=80&url=https%3A%2F%2Fexample.com%2Fimage%2520test.jpg&cd=1234"`
- )
- })
-
- it.each([
- [`width`, `w`, 100],
- [`height`, `h`, 50],
- [`cropFocus`, `crop`, `center,right`],
- [`format`, `fm`, `webp`],
- [`quality`, `q`, 60],
- ] as Array<[keyof ImageArgs, string, ImageArgs[keyof ImageArgs]]>)(
- `should set %s in image args`,
- (key, queryKey, value) => {
- const url = new URL(
- // @ts-ignore remove typings
- `https://netlify.com${generateImageUrlAlt(source, {
- format: `webp`,
- [key]: value,
- })}`
- )
-
- expect(url.searchParams.get(queryKey)).toEqual(value.toString())
- }
- )
-})
diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts
index 79e3ec108db00..a64692bf93e52 100644
--- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts
+++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts
@@ -3,7 +3,11 @@ import { basename, extname } from "path"
import { URL } from "url"
import { createContentDigest } from "gatsby-core-utils/create-content-digest"
import { isImage } from "../types"
-import type { ImageCropFocus, WidthOrHeight } from "../types"
+import type {
+ CustomImageCdnUrlGeneratorFn,
+ ImageCdnSourceImage,
+ ImageCdnTransformArgs,
+} from "../types"
import type { Store } from "gatsby"
// this is an arbitrary origin that we use #branding so we can construct a full url for the URL constructor
@@ -66,6 +70,12 @@ export function generateFileUrl(
},
store?: Store
): string {
+ const state = store?.getState()
+
+ const pathPrefix = state?.program?.prefixPaths
+ ? state?.config?.pathPrefix
+ : ``
+
const fileExt = extname(filename)
const filenameWithoutExt = basename(filename, fileExt)
@@ -74,7 +84,7 @@ export function generateFileUrl(
{
url,
},
- store
+ pathPrefix
)}/${filenameWithoutExt}${fileExt}`
)
@@ -83,24 +93,36 @@ export function generateFileUrl(
return `${frontendHostName}${parsedURL.pathname}${parsedURL.search}`
}
+let customImageCDNUrlGenerator: CustomImageCdnUrlGeneratorFn | undefined =
+ undefined
+
+const preferDefault = (m: any): any => (m && m.default) || m
+
export function generateImageUrl(
- source: {
- url: string
- mimeType: string
- filename: string
- internal: { contentDigest: string }
- },
- imageArgs: Parameters[0],
+ source: ImageCdnSourceImage,
+ imageArgs: ImageCdnTransformArgs,
store?: Store
): string {
- if (process.env.NETLIFY_IMAGE_CDN) {
- return generateImageUrlAlt(source, imageArgs)
+ const state = store?.getState()
+
+ const pathPrefix = state?.program?.prefixPaths
+ ? state?.config?.pathPrefix
+ : ``
+
+ if (global.__GATSBY?.imageCDNUrlGeneratorModulePath) {
+ if (!customImageCDNUrlGenerator) {
+ customImageCDNUrlGenerator = preferDefault(
+ require(global.__GATSBY.imageCDNUrlGeneratorModulePath)
+ ) as CustomImageCdnUrlGeneratorFn
+ }
+ return customImageCDNUrlGenerator(source, imageArgs, pathPrefix)
}
+
const filenameWithoutExt = basename(source.filename, extname(source.filename))
const queryStr = generateImageArgs(imageArgs)
const parsedURL = new URL(
- `${ORIGIN}${generatePublicUrl(source, store)}/${createContentDigest(
+ `${ORIGIN}${generatePublicUrl(source, pathPrefix)}/${createContentDigest(
queryStr
)}/${filenameWithoutExt}.${imageArgs.format}`
)
@@ -125,14 +147,8 @@ function generatePublicUrl(
url: string
mimeType?: string
},
- store?: Store
+ pathPrefix: string
): string {
- const state = store?.getState()
-
- const pathPrefix = state?.program?.prefixPaths
- ? state?.config?.pathPrefix
- : ``
-
const remoteUrl = createContentDigest(url)
let publicUrl =
@@ -152,11 +168,7 @@ function generateImageArgs({
format,
cropFocus,
quality,
-}: WidthOrHeight & {
- format: string
- cropFocus?: ImageCropFocus | Array
- quality: number
-}): string {
+}: ImageCdnTransformArgs): string {
const args: Array = []
if (width) {
args.push(`w=${width}`)
@@ -175,64 +187,3 @@ function generateImageArgs({
return args.join(`&`)
}
-
-export function generateImageUrlAlt(
- source: {
- url: string
- filename: string
- mimeType: string
- internal: { contentDigest: string }
- },
- imageArgs: Parameters[0]
-): string {
- const placeholderOrigin = `http://netlify.com`
- const imageParams = generateImageArgsAlt(imageArgs)
-
- const baseURL = new URL(`${placeholderOrigin}/.netlify/images`)
-
- baseURL.search = imageParams.toString()
- baseURL.searchParams.append(`url`, source.url)
- baseURL.searchParams.append(`cd`, source.internal.contentDigest)
-
- return `${baseURL.pathname}${baseURL.search}`
-}
-
-export function generateImageArgsAlt({
- width,
- height,
- format,
- cropFocus,
- quality,
-}: WidthOrHeight & {
- format: string
- cropFocus?: ImageCropFocus | Array
- quality: number
-}): URLSearchParams {
- const params = new URLSearchParams()
-
- if (width) {
- params.append(`w`, width.toString())
- }
- if (height) {
- params.append(`h`, height.toString())
- }
- if (cropFocus) {
- params.append(`fit`, `crop`)
- if (Array.isArray(cropFocus)) {
- // For array of cropFocus values, append them as comma-separated string
- params.append(`crop`, cropFocus.join(`,`))
- } else {
- params.append(`crop`, cropFocus)
- }
- }
-
- if (format) {
- params.append(`fm`, format)
- }
-
- if (quality) {
- params.append(`q`, quality.toString())
- }
-
- return params
-}
diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts
index 3adbc7584892e..a450eed930408 100644
--- a/packages/gatsby/index.d.ts
+++ b/packages/gatsby/index.d.ts
@@ -45,6 +45,9 @@ export {
HeaderRoutes,
FunctionsManifest,
IAdapterConfig,
+ ImageCdnUrlGeneratorFn,
+ ImageCdnSourceImage,
+ ImageCdnTransformArgs,
} from "./dist/utils/adapter/types"
export const useScrollRestoration: (key: string) => {
diff --git a/packages/gatsby/src/utils/adapter/manager.ts b/packages/gatsby/src/utils/adapter/manager.ts
index 5c56f16d64c2d..6d6023dee7e4f 100644
--- a/packages/gatsby/src/utils/adapter/manager.ts
+++ b/packages/gatsby/src/utils/adapter/manager.ts
@@ -251,6 +251,11 @@ export async function initAdapterManager(): Promise {
`Can't exclude datastore from engine function without adapter providing deployURL`
)
}
+
+ if (configFromAdapter?.imageCDNUrlGeneratorModulePath) {
+ global.__GATSBY.imageCDNUrlGeneratorModulePath =
+ configFromAdapter.imageCDNUrlGeneratorModulePath
+ }
}
return {
diff --git a/packages/gatsby/src/utils/adapter/types.ts b/packages/gatsby/src/utils/adapter/types.ts
index 8e004b93d16b1..cacce9295a37b 100644
--- a/packages/gatsby/src/utils/adapter/types.ts
+++ b/packages/gatsby/src/utils/adapter/types.ts
@@ -2,6 +2,12 @@ import type reporter from "gatsby-cli/lib/reporter"
import type { TrailingSlash } from "gatsby-page-utils"
import type { IHeader, HttpStatusCode } from "../../redux/types"
+export type {
+ ImageCdnUrlGeneratorFn,
+ ImageCdnSourceImage,
+ ImageCdnTransformArgs,
+} from "gatsby-plugin-utils"
+
interface IBaseRoute {
/**
* Request path that should be matched for this route.
@@ -149,6 +155,10 @@ export interface IAdapterConfig {
* plugin and adapter is used at the same time.
*/
pluginsToDisable?: Array
+ /**
+ * TODO: write description
+ */
+ imageCDNUrlGeneratorModulePath?: string
}
type WithRequired = T & { [P in K]-?: T[P] }
diff --git a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts
index 6b253093f6c2a..1590c27cbcf5d 100644
--- a/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts
+++ b/packages/gatsby/src/utils/page-ssr-module/bundle-webpack.ts
@@ -222,19 +222,32 @@ export async function createPageSSRBundle({
].filter(Boolean) as Array,
})
+ let IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = ``
+ if (global.__GATSBY?.imageCDNUrlGeneratorModulePath) {
+ await fs.copyFile(
+ global.__GATSBY.imageCDNUrlGeneratorModulePath,
+ path.join(outputDir, `image-cdn-url-generator.js`)
+ )
+ IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH = `./image-cdn-url-generator.js`
+ }
+
let functionCode = await fs.readFile(
path.join(__dirname, `lambda.js`),
`utf-8`
)
functionCode = functionCode
- .replace(
+ .replaceAll(
`%CDN_DATASTORE_PATH%`,
shouldBundleDatastore()
? ``
: `${state.adapter.config.deployURL ?? ``}/${LmdbOnCdnPath}`
)
- .replace(`%PATH_PREFIX%`, pathPrefix)
+ .replaceAll(`%PATH_PREFIX%`, pathPrefix)
+ .replaceAll(
+ `%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`,
+ IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH
+ )
await fs.outputFile(path.join(outputDir, `lambda.js`), functionCode)
diff --git a/packages/gatsby/src/utils/page-ssr-module/lambda.ts b/packages/gatsby/src/utils/page-ssr-module/lambda.ts
index c051aff44010b..3b03052f64cc8 100644
--- a/packages/gatsby/src/utils/page-ssr-module/lambda.ts
+++ b/packages/gatsby/src/utils/page-ssr-module/lambda.ts
@@ -27,10 +27,7 @@ function setupFsWrapper(): string {
const TEMP_DIR = path.join(tmpdir(), `gatsby`)
const TEMP_CACHE_DIR = path.join(TEMP_DIR, `.cache`)
- global.__GATSBY = {
- root: TEMP_DIR,
- buildId: ``,
- }
+ global.__GATSBY.root = TEMP_DIR
// TODO: don't hardcode this
const cacheDir = `/var/task/.cache`
@@ -90,6 +87,18 @@ function setupFsWrapper(): string {
}
}
+global.__GATSBY = {
+ root: process.cwd(),
+ buildId: ``,
+}
+
+// eslint-disable-next-line no-constant-condition
+if (`%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`) {
+ global.__GATSBY.imageCDNUrlGeneratorModulePath = require.resolve(
+ `%IMAGE_CDN_URL_GENERATOR_MODULE_RELATIVE_PATH%`
+ )
+}
+
const dbPath = setupFsWrapper()
// using require instead of import here for now because of type hell + import path doesn't exist in current context
diff --git a/types/gatsby-monorepo/global.d.ts b/types/gatsby-monorepo/global.d.ts
index 192414cb53765..1fe3271d47df8 100644
--- a/types/gatsby-monorepo/global.d.ts
+++ b/types/gatsby-monorepo/global.d.ts
@@ -7,6 +7,7 @@ declare module NodeJS {
__GATSBY: {
buildId: string
root: string
+ imageCDNUrlGeneratorModulePath?: string
}
_polyfillRemoteFileCache?: import("gatsby").GatsbyCache