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 ( +
+

+ + {node.filename} + +

+ +
+ + + + +
+
+ ) + })} +
+ ) +} + +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 ( +
+

+ + {node.filename} + +

+ +
+ + + + +
+
+ ) + })} +
+ ) +} + +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